Account migration between instances !18
6 files changed, +561 −0
Layout: unified · split
cmd/gitbay/main.go +3
| @@ -35,6 +35,7 @@ func main() { | |||
| 35 | milestoneCmd(), | 35 | milestoneCmd(), |
| 36 | mrCmd(), | 36 | mrCmd(), |
| 37 | releaseCmd(), | 37 | releaseCmd(), |
| 38 | migrateCmd(), | ||
| 38 | webCmd(), | 39 | webCmd(), |
| 39 | orgCmd(), | 40 | orgCmd(), |
| 40 | group("profile", "user and org profiles", | 41 | group("profile", "user and org profiles", |
| @@ -199,6 +200,8 @@ func authCmd() *cobra.Command { | |||
| 199 | pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}), | 200 | pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}), |
| 200 | ) | 201 | ) |
| 201 | return group("auth", "identity: whoami, SSH and PGP keys", | 202 | return group("auth", "identity: whoami, SSH and PGP keys", |
| 203 | pass("export", "write your account bundle (a user-level backup) to stdout", | ||
| 204 | passOpts{server: []string{"account", "export"}}), | ||
| 202 | tokens, | 205 | tokens, |
| 203 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), | 206 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), |
| 204 | group("keys", "manage SSH keys", | 207 | group("keys", "manage SSH keys", |
cmd/gitbay/migrate.go added +124
| @@ -0,0 +1,124 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/spf13/cobra" | ||
| 12 | |||
| 13 | "gitbay.org/gitbay/internal/protocol" | ||
| 14 | ) | ||
| 15 | |||
| 16 | func migrateCmd() *cobra.Command { | ||
| 17 | var from string | ||
| 18 | var fromPort int | ||
| 19 | cmd := &cobra.Command{ | ||
| 20 | Use: "migrate", | ||
| 21 | Short: "move your account here from another gitbay instance: gitbay migrate --from <host>", | ||
| 22 | Long: `Migrate authenticates to the source instance with your own SSH key, | ||
| 23 | exports your account bundle (profile, emails, repos with settings, | ||
| 24 | issues, MRs, comments), replays it on this instance, then mirrors each | ||
| 25 | repository's git data client-side: clone from the source, push here. | ||
| 26 | Keys are never transferred and emails arrive unverified — trust is | ||
| 27 | per-instance. Re-running resumes; nothing imports twice.`, | ||
| 28 | Args: cobra.NoArgs, | ||
| 29 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 30 | os.Exit(runMigrate(from, fromPort)) | ||
| 31 | return nil | ||
| 32 | }, | ||
| 33 | } | ||
| 34 | cmd.Flags().StringVar(&from, "from", "", "source instance host (required)") | ||
| 35 | cmd.Flags().IntVar(&fromPort, "from-port", 22, "source instance SSH port") | ||
| 36 | return cmd | ||
| 37 | } | ||
| 38 | |||
| 39 | func sourceSSH(host string, port int, extra ...string) *exec.Cmd { | ||
| 40 | args := []string{} | ||
| 41 | if port != 0 && port != 22 { | ||
| 42 | args = append(args, "-p", fmt.Sprint(port)) | ||
| 43 | } | ||
| 44 | args = append(args, "git@"+host, "--") | ||
| 45 | args = append(args, extra...) | ||
| 46 | return exec.Command("ssh", args...) | ||
| 47 | } | ||
| 48 | |||
| 49 | func runMigrate(from string, fromPort int) int { | ||
| 50 | if from == "" { | ||
| 51 | fmt.Fprintln(os.Stderr, "gitbay: --from <host> is required") | ||
| 52 | return protocol.ExitUsage | ||
| 53 | } | ||
| 54 | t, err := resolveTarget() | ||
| 55 | if err != nil { | ||
| 56 | fmt.Fprintln(os.Stderr, "gitbay:", err) | ||
| 57 | return protocol.ExitFailure | ||
| 58 | } | ||
| 59 | if t.inst.Host == from { | ||
| 60 | fmt.Fprintln(os.Stderr, "gitbay: --from is this instance; migrate runs on the TARGET with the source in --from") | ||
| 61 | return protocol.ExitUsage | ||
| 62 | } | ||
| 63 | |||
| 64 | fmt.Fprintf(os.Stderr, "exporting account bundle from %s ...\n", from) | ||
| 65 | exp := sourceSSH(from, fromPort, "account", "export") | ||
| 66 | var bundleBuf, expErr bytes.Buffer | ||
| 67 | exp.Stdout, exp.Stderr = &bundleBuf, &expErr | ||
| 68 | if err := exp.Run(); err != nil { | ||
| 69 | fmt.Fprintf(os.Stderr, "gitbay: export from %s failed: %v\n%s", from, err, expErr.String()) | ||
| 70 | return protocol.ExitProtocol | ||
| 71 | } | ||
| 72 | |||
| 73 | var b struct { | ||
| 74 | Username string `json:"username"` | ||
| 75 | Repos []struct { | ||
| 76 | Name string `json:"name"` | ||
| 77 | } `json:"repos"` | ||
| 78 | } | ||
| 79 | if err := json.Unmarshal(bundleBuf.Bytes(), &b); err != nil { | ||
| 80 | fmt.Fprintf(os.Stderr, "gitbay: bundle from %s does not parse: %v\n", from, err) | ||
| 81 | return protocol.ExitProtocol | ||
| 82 | } | ||
| 83 | |||
| 84 | fmt.Fprintf(os.Stderr, "replaying metadata on %s ...\n", t.inst.Host) | ||
| 85 | if code := runSSH(t, []string{"account", "import-bundle", "--source", from}, &bundleBuf); code != 0 { | ||
| 86 | return code | ||
| 87 | } | ||
| 88 | |||
| 89 | // Git data travels client-side: your key authenticates both ends. | ||
| 90 | for _, r := range b.Repos { | ||
| 91 | repoPath := b.Username + "/" + r.Name | ||
| 92 | fmt.Fprintf(os.Stderr, "mirroring %s ...\n", repoPath) | ||
| 93 | tmp, err := os.MkdirTemp("", "gitbay-migrate-*") | ||
| 94 | if err != nil { | ||
| 95 | fmt.Fprintln(os.Stderr, "gitbay:", err) | ||
| 96 | return protocol.ExitFailure | ||
| 97 | } | ||
| 98 | srcURL := fmt.Sprintf("ssh://git@%s/%s.git", from, repoPath) | ||
| 99 | if fromPort != 0 && fromPort != 22 { | ||
| 100 | srcURL = fmt.Sprintf("ssh://git@%s:%d/%s.git", from, fromPort, repoPath) | ||
| 101 | } | ||
| 102 | clone := exec.Command("git", "clone", "--quiet", "--mirror", srcURL, tmp+"/r") | ||
| 103 | clone.Stderr = os.Stderr | ||
| 104 | if err := clone.Run(); err != nil { | ||
| 105 | fmt.Fprintf(os.Stderr, "gitbay: cloning %s failed; fix and re-run migrate (it resumes)\n", repoPath) | ||
| 106 | os.RemoveAll(tmp) | ||
| 107 | return protocol.ExitFailure | ||
| 108 | } | ||
| 109 | push := exec.Command("git", "-C", tmp+"/r", "push", "--quiet", t.inst.CloneURL(repoPath), | ||
| 110 | "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*") | ||
| 111 | if len(t.inst.SSHOptions) > 0 { | ||
| 112 | push.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " ")) | ||
| 113 | } | ||
| 114 | push.Stderr = os.Stderr | ||
| 115 | err = push.Run() | ||
| 116 | os.RemoveAll(tmp) | ||
| 117 | if err != nil { | ||
| 118 | fmt.Fprintf(os.Stderr, "gitbay: pushing %s failed; fix and re-run migrate (it resumes)\n", repoPath) | ||
| 119 | return protocol.ExitFailure | ||
| 120 | } | ||
| 121 | } | ||
| 122 | fmt.Fprintf(os.Stderr, "migration complete: %d repositories. Re-register extra keys, re-verify emails,\nand re-apply any deferred policies printed above.\n", len(b.Repos)) | ||
| 123 | return protocol.ExitOK | ||
| 124 | } | ||
docs/users.org +13
| @@ -130,6 +130,19 @@ gitbay repo import you/mirror --from https://github.com/you/repo.git \ | |||
| 130 | gitbay repo import-issues you/mirror --from you/repo --token-stdin | 130 | gitbay repo import-issues you/mirror --from you/repo --token-stdin |
| 131 | #+end_src | 131 | #+end_src |
| 132 | 132 | ||
| 133 | Moving between gitbay instances (no lock-in): run on the TARGET, with | ||
| 134 | your key registered on both sides. Profile, repos with settings, | ||
| 135 | issues, MRs, and comments replay with attribution; git data mirrors | ||
| 136 | client-side through your own key. Keys never transfer and emails | ||
| 137 | arrive unverified — trust is per-instance. Re-running resumes. | ||
| 138 | Push-blocking policies (require-signed, protected branches) are | ||
| 139 | deferred and printed for you to re-apply after the data lands. | ||
| 140 | =gitbay auth export= alone doubles as a user-level backup. | ||
| 141 | |||
| 142 | #+begin_src sh | ||
| 143 | gitbay migrate --from old-instance.example [--from-port 22] | ||
| 144 | #+end_src | ||
| 145 | |||
| 133 | Mirroring keeps a foreign remote in sync during a gradual migration | 146 | Mirroring keeps a foreign remote in sync during a gradual migration |
| 134 | (repo admin; https remotes; the token is stored server-side for the | 147 | (repo admin; https remotes; the token is stored server-side for the |
| 135 | recurring sync and never echoed back): | 148 | recurring sync and never echoed back): |
e2e/migrate_test.go added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestAccountMigration(t *testing.T) { | ||
| 11 | src := startInstance(t) | ||
| 12 | dst := startInstance(t) | ||
| 13 | key := src.newKey(t, "alice") // one identity, both instances | ||
| 14 | src.admin(t, "admin", "user", "create", "alice", "--key", key+".pub") | ||
| 15 | dst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub") | ||
| 16 | |||
| 17 | // Source: profile, a repo with settings/topics, an issue thread, an MR. | ||
| 18 | if _, _, code := src.ssh(t, key, "", "profile", "set", "--description", "'tinkerer'", "--website", "https://alice.example"); code != 0 { | ||
| 19 | t.Fatal("profile set failed") | ||
| 20 | } | ||
| 21 | if _, _, code := src.ssh(t, key, "", "repo", "create", "alice/tool", "--description", "'a fine tool'"); code != 0 { | ||
| 22 | t.Fatal("repo create failed") | ||
| 23 | } | ||
| 24 | if _, _, code := src.ssh(t, key, "", "repo", "create", "alice/notes", "--private"); code != 0 { | ||
| 25 | t.Fatal("private repo create failed") | ||
| 26 | } | ||
| 27 | src.ssh(t, key, "", "repo", "topics", "add", "alice/tool", "go", "cli") | ||
| 28 | work := t.TempDir() | ||
| 29 | env := src.gitEnv(key) | ||
| 30 | mustGit(t, work, env, "clone", src.sshURL("alice/tool"), "w") | ||
| 31 | dir := filepath.Join(work, "w") | ||
| 32 | os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o644) | ||
| 33 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 34 | mustGit(t, dir, env, "add", ".") | ||
| 35 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 36 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 37 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | ||
| 38 | src.ssh(t, key, "", "repo", "settings", "protect", "alice/tool", "main") | ||
| 39 | src.ssh(t, key, "", "repo", "settings", "require-signed", "alice/tool", "on") | ||
| 40 | if _, _, code := src.ssh(t, key, "", "issue", "create", "alice/tool", "--title", "'sharpen it'", "--body", "'too dull'"); code != 0 { | ||
| 41 | t.Fatal("issue create failed") | ||
| 42 | } | ||
| 43 | src.ssh(t, key, "", "issue", "label", "alice/tool", "1", "--add", "bug") | ||
| 44 | src.ssh(t, key, "", "issue", "comment", "alice/tool", "1", "--message", "'on it'") | ||
| 45 | src.ssh(t, key, "", "issue", "close", "alice/tool", "1") | ||
| 46 | |||
| 47 | // Export from the source, replay on the target. | ||
| 48 | bundleOut, errOut, code := src.ssh(t, key, "", "account", "export") | ||
| 49 | if code != 0 { | ||
| 50 | t.Fatalf("export: %s", errOut) | ||
| 51 | } | ||
| 52 | if strings.Contains(bundleOut, "ssh-ed25519") || strings.Contains(bundleOut, "PRIVATE") { | ||
| 53 | t.Fatal("bundle carries key material") | ||
| 54 | } | ||
| 55 | out, errOut, code := dst.ssh(t, key, bundleOut, "account", "import-bundle", "--source", "old.example") | ||
| 56 | if code != 0 { | ||
| 57 | t.Fatalf("import-bundle: %s", errOut) | ||
| 58 | } | ||
| 59 | if !strings.Contains(out, "imported 2 repos, 1 issues, 0 MRs, 1 comments") || | ||
| 60 | !strings.Contains(out, "require-signed alice/tool on") || | ||
| 61 | !strings.Contains(out, "protect alice/tool main") { | ||
| 62 | t.Fatalf("import summary: %s", out) | ||
| 63 | } | ||
| 64 | |||
| 65 | // Metadata arrived: issue with state, label, comment, attribution; | ||
| 66 | // topics, description, visibility. | ||
| 67 | out, _, _ = dst.ssh(t, key, "", "issue", "show", "alice/tool", "1", "--json") | ||
| 68 | if !strings.Contains(out, "sharpen it") || !strings.Contains(out, `"state":"closed"`) || | ||
| 69 | !strings.Contains(out, `"labels":["bug"]`) || !strings.Contains(out, "on it") || | ||
| 70 | !strings.Contains(out, "migrated issue old.example#1") { | ||
| 71 | t.Fatalf("migrated issue: %s", out) | ||
| 72 | } | ||
| 73 | out, _, _ = dst.ssh(t, key, "", "repo", "show", "alice/tool", "--json") | ||
| 74 | if !strings.Contains(out, `"topics":["cli","go"]`) || !strings.Contains(out, "a fine tool") { | ||
| 75 | t.Fatalf("migrated repo: %s", out) | ||
| 76 | } | ||
| 77 | out, _, _ = dst.ssh(t, key, "", "repo", "show", "alice/notes", "--json") | ||
| 78 | if !strings.Contains(out, `"visibility":"private"`) { | ||
| 79 | t.Fatalf("private visibility lost: %s", out) | ||
| 80 | } | ||
| 81 | // Deferred policies are NOT applied yet — the push must succeed. | ||
| 82 | out, _, _ = dst.ssh(t, key, "", "repo", "settings", "show", "alice/tool", "--json") | ||
| 83 | if strings.Contains(out, `"require_signed_commits":true`) || strings.Contains(out, "protected") { | ||
| 84 | t.Fatalf("push-blocking settings applied early: %s", out) | ||
| 85 | } | ||
| 86 | |||
| 87 | // Git data client-side: clone from source, push to target (unsigned | ||
| 88 | // history lands because the policy is deferred). | ||
| 89 | mirror := t.TempDir() | ||
| 90 | mustGit(t, mirror, env, "clone", "-q", "--mirror", src.sshURL("alice/tool"), "m") | ||
| 91 | mdir := filepath.Join(mirror, "m") | ||
| 92 | mustGit(t, mdir, env, "push", "-q", dst.sshURL("alice/tool"), "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*") | ||
| 93 | out, _, _ = dst.ssh(t, key, "", "repo", "log", "alice/tool", "--json") | ||
| 94 | if !strings.Contains(out, head) { | ||
| 95 | t.Fatalf("git data missing on target: %s", out) | ||
| 96 | } | ||
| 97 | |||
| 98 | // Resume: a second replay imports nothing twice. | ||
| 99 | out, _, code = dst.ssh(t, key, bundleOut, "account", "import-bundle", "--source", "old.example") | ||
| 100 | if code != 0 || !strings.Contains(out, "imported 0 repos, 0 issues, 0 MRs, 0 comments (1 already present)") { | ||
| 101 | t.Fatalf("re-import: %s", out) | ||
| 102 | } | ||
| 103 | } | ||
internal/control/migrate.go added +300
| @@ -0,0 +1,300 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 14 | func 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. | ||
| 25 | const bundleVersion = "gitbay-account/1" | ||
| 26 | |||
| 27 | type bundleComment struct { | ||
| 28 | Author string `json:"author"` | ||
| 29 | Body string `json:"body"` | ||
| 30 | CreatedAt string `json:"created_at"` | ||
| 31 | } | ||
| 32 | type 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 | } | ||
| 42 | type 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 | } | ||
| 53 | type 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 | } | ||
| 63 | type 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 | |||
| 71 | func 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") | ||
| 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") | ||
| 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 | |||
| 129 | func 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. | ||
| 139 | func 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 | |||
| 300 | var _ = strings.TrimSpace // placeholder against accidental import drops | ||
internal/store/users.go +18
| @@ -60,6 +60,24 @@ func (s *Store) UserByUsername(name string) (User, error) { | |||
| 60 | return u, err | 60 | return u, err |
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | // UserEmailAddresses returns every address on the account, verified or not. | ||
| 64 | func (s *Store) UserEmailAddresses(userID int64) ([]string, error) { | ||
| 65 | rows, err := s.DB.Query("SELECT address FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address", userID) | ||
| 66 | if err != nil { | ||
| 67 | return nil, err | ||
| 68 | } | ||
| 69 | defer rows.Close() | ||
| 70 | var out []string | ||
| 71 | for rows.Next() { | ||
| 72 | var a string | ||
| 73 | if err := rows.Scan(&a); err != nil { | ||
| 74 | return nil, err | ||
| 75 | } | ||
| 76 | out = append(out, a) | ||
| 77 | } | ||
| 78 | return out, rows.Err() | ||
| 79 | } | ||
| 80 | |||
| 63 | // SetUserDisabled suspends or restores an account. Disabling also drops | 81 | // SetUserDisabled suspends or restores an account. Disabling also drops |
| 64 | // the user's web sessions; their keys and tokens stay registered but are | 82 | // the user's web sessions; their keys and tokens stay registered but are |
| 65 | // refused at every entry point until re-enabled. | 83 | // refused at every entry point until re-enabled. |