Commit ee64c2c79b
Verified · cmc
cmd/gitbay/main.go +3
| @@ -35,6 +35,7 @@ func main() { | ||
| 35 | 35 | milestoneCmd(), |
| 36 | 36 | mrCmd(), |
| 37 | 37 | releaseCmd(), |
| 38 | migrateCmd(), | |
| 38 | 39 | webCmd(), |
| 39 | 40 | orgCmd(), |
| 40 | 41 | group("profile", "user and org profiles", |
| @@ -199,6 +200,8 @@ func authCmd() *cobra.Command { | ||
| 199 | 200 | pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}), |
| 200 | 201 | ) |
| 201 | 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 | 205 | tokens, |
| 203 | 206 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), |
| 204 | 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 | 130 | gitbay repo import-issues you/mirror --from you/repo --token-stdin |
| 131 | 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 | 146 | Mirroring keeps a foreign remote in sync during a gradual migration |
| 134 | 147 | (repo admin; https remotes; the token is stored server-side for the |
| 135 | 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 | 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 | 81 | // SetUserDisabled suspends or restores an account. Disabling also drops |
| 64 | 82 | // the user's web sessions; their keys and tokens stay registered but are |
| 65 | 83 | // refused at every entry point until re-enabled. |