A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit ee64c2c79b

ee64c2c79b5ba597d25d2a337d9c53b320ebfece

parent: 85ac4d96b9

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T21:49:32Z

Add account migration between gitbay instances

Closes #29

account export writes the user's bundle as JSON (profile, emails as
addresses only, user-owned repos with settings/topics/descriptions,
issues and MRs with comments) — keys are never exported and emails
arrive unverified: trust is per-instance. Also exposed as gitbay auth
export, doubling as a user-level backup.

account import-bundle replays it under the calling user: repos created
with visibility/description/topics, issues and MRs with inline
attribution, resumable via import markers. Push-blocking policies
(require_signed_commits, protected_branches) are deferred and reported
so the git push that follows cannot be refused; git_daemon resets to
off (instance-dependent).

gitbay migrate --from <host> orchestrates the whole move client-side:
export over the user's own key, replay on the target, then
clone --mirror from the source and push branches+tags to the target —
the server never needs credentials for either side.
cmd/gitbay/main.go +3
@@ -35,6 +35,7 @@ func main() {
3535 milestoneCmd(),
3636 mrCmd(),
3737 releaseCmd(),
38 migrateCmd(),
3839 webCmd(),
3940 orgCmd(),
4041 group("profile", "user and org profiles",
@@ -199,6 +200,8 @@ func authCmd() *cobra.Command {
199200 pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}),
200201 )
201202 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"}}),
202205 tokens,
203206 pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
204207 group("keys", "manage SSH keys",
cmd/gitbay/migrate.go added +124
@@ -0,0 +1,124 @@
1package main
2
3import (
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
16func 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,
23exports your account bundle (profile, emails, repos with settings,
24issues, MRs, comments), replays it on this instance, then mirrors each
25repository's git data client-side: clone from the source, push here.
26Keys are never transferred and emails arrive unverified — trust is
27per-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
39func 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
49func 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 \
130130 gitbay repo import-issues you/mirror --from you/repo --token-stdin
131131 #+end_src
132132
133Moving between gitbay instances (no lock-in): run on the TARGET, with
134your key registered on both sides. Profile, repos with settings,
135issues, MRs, and comments replay with attribution; git data mirrors
136client-side through your own key. Keys never transfer and emails
137arrive unverified — trust is per-instance. Re-running resumes.
138Push-blocking policies (require-signed, protected branches) are
139deferred 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
143gitbay migrate --from old-instance.example [--from-port 22]
144#+end_src
145
133146 Mirroring keeps a foreign remote in sync during a gradual migration
134147 (repo admin; https remotes; the token is stored server-side for the
135148 recurring sync and never echoed back):
e2e/migrate_test.go added +103
@@ -0,0 +1,103 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func 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 @@
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")
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
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 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
300var _ = strings.TrimSpace // placeholder against accidental import drops
internal/store/users.go +18
@@ -60,6 +60,24 @@ func (s *Store) UserByUsername(name string) (User, error) {
6060 return u, err
6161 }
6262
63// UserEmailAddresses returns every address on the account, verified or not.
64func (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
6381 // SetUserDisabled suspends or restores an account. Disabling also drops
6482 // the user's web sessions; their keys and tokens stay registered but are
6583 // refused at every entry point until re-enabled.