Commit 4b19481915
Verified · cmc
cmd/gitbay/main.go +5
| @@ -138,6 +138,9 @@ func usesStdin(args []string) bool { | ||
| 138 | 138 | if a == "--file" && i+1 < len(args) && args[i+1] == "-" { |
| 139 | 139 | return true |
| 140 | 140 | } |
| 141 | if a == "--token-stdin" { | |
| 142 | return true | |
| 143 | } | |
| 141 | 144 | } |
| 142 | 145 | return false |
| 143 | 146 | } |
| @@ -233,6 +236,8 @@ func repoCmd() *cobra.Command { | ||
| 233 | 236 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), |
| 234 | 237 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
| 235 | 238 | importCmd(), |
| 239 | pass("import-issues", "import GitHub issue/PR history: --from <ghowner/ghrepo> [--token-stdin]", | |
| 240 | passOpts{server: []string{"repo", "import-issues"}, needsRepo: true, stdinOK: true}), | |
| 236 | 241 | group("deploy-key", "repository-bound CI keys", |
| 237 | 242 | pass("add", "bind a key: [--rw] < key.pub", passOpts{server: []string{"repo", "deploy-key", "add"}, needsRepo: true, stdinOK: true}), |
| 238 | 243 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
docs/users.org +6 −2
| @@ -116,11 +116,15 @@ gitbay repo archive you/project # read-only: pushes and issu | ||
| 116 | 116 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 117 | 117 | #+end_src |
| 118 | 118 | |
| 119 | Import from another forge (git data only — issues and PRs do not | |
| 120 | transfer): | |
| 119 | Import from another forge — git data first, then optionally the GitHub | |
| 120 | issue and PR history (issues keep state/labels/comments; PRs land as | |
| 121 | closed or merged MRs with their discussion; originals are attributed | |
| 122 | inline since foreign authors have no local account; re-running resumes | |
| 123 | where it stopped): | |
| 121 | 124 | #+begin_src sh |
| 122 | 125 | gitbay repo import you/mirror --from https://github.com/you/repo.git \ |
| 123 | 126 | [--private] [--token-stdin] # token on stdin, never in the URL |
| 127 | gitbay repo import-issues you/mirror --from you/repo --token-stdin | |
| 124 | 128 | #+end_src |
| 125 | 129 | |
| 126 | 130 | * Organizations |
e2e/ghimport_test.go added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/http" | |
| 6 | "net/http/httptest" | |
| 7 | "os" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ) | |
| 12 | ||
| 13 | // fakeGitHub serves just enough of the GitHub REST API for the importer. | |
| 14 | func fakeGitHub(t *testing.T) *httptest.Server { | |
| 15 | t.Helper() | |
| 16 | mux := http.NewServeMux() | |
| 17 | auth := func(w http.ResponseWriter, r *http.Request) bool { | |
| 18 | if r.Header.Get("Authorization") != "Bearer sekrit" { | |
| 19 | w.WriteHeader(401) | |
| 20 | return false | |
| 21 | } | |
| 22 | return true | |
| 23 | } | |
| 24 | mux.HandleFunc("/repos/octo/legacy/issues", func(w http.ResponseWriter, r *http.Request) { | |
| 25 | if !auth(w, r) { | |
| 26 | return | |
| 27 | } | |
| 28 | if r.URL.Query().Get("page") != "1" { | |
| 29 | fmt.Fprint(w, "[]") | |
| 30 | return | |
| 31 | } | |
| 32 | fmt.Fprint(w, `[ | |
| 33 | {"number":1,"title":"old bug","body":"it crashed","state":"closed", | |
| 34 | "created_at":"2019-03-04T10:00:00Z","user":{"login":"octofan"}, | |
| 35 | "labels":[{"name":"bug"}],"comments":0}, | |
| 36 | {"number":2,"title":"add feature","body":"the patch","state":"closed", | |
| 37 | "created_at":"2020-06-01T10:00:00Z","user":{"login":"drive-by"}, | |
| 38 | "labels":[],"comments":1,"pull_request":{}}, | |
| 39 | {"number":3,"title":"still open","body":"discuss","state":"open", | |
| 40 | "created_at":"2021-01-01T10:00:00Z","user":{"login":"octofan"}, | |
| 41 | "labels":[],"comments":2} | |
| 42 | ]`) | |
| 43 | }) | |
| 44 | mux.HandleFunc("/repos/octo/legacy/pulls/2", func(w http.ResponseWriter, r *http.Request) { | |
| 45 | if !auth(w, r) { | |
| 46 | return | |
| 47 | } | |
| 48 | fmt.Fprint(w, `{"merged_at":"2020-06-02T10:00:00Z", | |
| 49 | "head":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref":"feature"}, | |
| 50 | "base":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","ref":"main"}}`) | |
| 51 | }) | |
| 52 | comments := func(payload string) http.HandlerFunc { | |
| 53 | return func(w http.ResponseWriter, r *http.Request) { | |
| 54 | if !auth(w, r) { | |
| 55 | return | |
| 56 | } | |
| 57 | if r.URL.Query().Get("page") != "1" { | |
| 58 | fmt.Fprint(w, "[]") | |
| 59 | return | |
| 60 | } | |
| 61 | fmt.Fprint(w, payload) | |
| 62 | } | |
| 63 | } | |
| 64 | mux.HandleFunc("/repos/octo/legacy/issues/2/comments", comments( | |
| 65 | `[{"id":101,"body":"nice patch","created_at":"2020-06-01T11:00:00Z","user":{"login":"maintainer"}}]`)) | |
| 66 | mux.HandleFunc("/repos/octo/legacy/issues/3/comments", comments( | |
| 67 | `[{"id":102,"body":"me too","created_at":"2021-01-02T10:00:00Z","user":{"login":"other"}}, | |
| 68 | {"id":103,"body":"still happening","created_at":"2021-02-01T10:00:00Z","user":{"login":"octofan"}}]`)) | |
| 69 | srv := httptest.NewServer(mux) | |
| 70 | t.Cleanup(srv.Close) | |
| 71 | return srv | |
| 72 | } | |
| 73 | ||
| 74 | func TestGitHubIssueImport(t *testing.T) { | |
| 75 | // allow_local lets --api-base reach the loopback fake; a default | |
| 76 | // instance refuses it (see the SSRF check at the end). | |
| 77 | inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n") | |
| 78 | aliceKey := inst.newKey(t, "alice") | |
| 79 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 80 | ||
| 81 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 82 | t.Fatalf("repo create: %s", errOut) | |
| 83 | } | |
| 84 | work := t.TempDir() | |
| 85 | env := inst.gitEnv(aliceKey) | |
| 86 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 87 | dir := filepath.Join(work, "w") | |
| 88 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 89 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 90 | mustGit(t, dir, env, "add", ".") | |
| 91 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 92 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 93 | ||
| 94 | gh := fakeGitHub(t) | |
| 95 | out, errOut, code := inst.ssh(t, aliceKey, "sekrit\n", "repo", "import-issues", "alice/app", | |
| 96 | "--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL) | |
| 97 | if code != 0 { | |
| 98 | t.Fatalf("import: %s", errOut) | |
| 99 | } | |
| 100 | if !strings.Contains(out, "imported 2 issues, 1 merge requests, 3 comments") { | |
| 101 | t.Fatalf("summary: %s", out) | |
| 102 | } | |
| 103 | ||
| 104 | // Issue #1 (GitHub #1): closed, labeled, attributed. | |
| 105 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | |
| 106 | if !strings.Contains(out, "old bug") || !strings.Contains(out, `"state":"closed"`) || | |
| 107 | !strings.Contains(out, `"labels":["bug"]`) || | |
| 108 | !strings.Contains(out, "imported issue github.com/octo/legacy#1") || | |
| 109 | !strings.Contains(out, "@octofan, 2019-03-04") { | |
| 110 | t.Fatalf("issue 1: %s", out) | |
| 111 | } | |
| 112 | // Issue #2 (GitHub #3): open, two attributed comments. | |
| 113 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") | |
| 114 | if !strings.Contains(out, "still open") || !strings.Contains(out, `"state":"open"`) || | |
| 115 | !strings.Contains(out, "me too") || !strings.Contains(out, "@other, 2021-01-02") { | |
| 116 | t.Fatalf("issue 2: %s", out) | |
| 117 | } | |
| 118 | // MR !1 (GitHub PR #2): merged, discussion imported. | |
| 119 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json") | |
| 120 | if !strings.Contains(out, "add feature") || !strings.Contains(out, `"state":"merged"`) || | |
| 121 | !strings.Contains(out, "imported pull request github.com/octo/legacy#2") || | |
| 122 | !strings.Contains(out, "nice patch") { | |
| 123 | t.Fatalf("mr 1: %s", out) | |
| 124 | } | |
| 125 | ||
| 126 | // Re-running imports nothing new — fully resumable. | |
| 127 | out, _, code = inst.ssh(t, aliceKey, "sekrit\n", "repo", "import-issues", "alice/app", | |
| 128 | "--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL) | |
| 129 | if code != 0 || !strings.Contains(out, "imported 0 issues, 0 merge requests, 0 comments (3 items already imported)") { | |
| 130 | t.Fatalf("re-run: %s", out) | |
| 131 | } | |
| 132 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--state", "all") | |
| 133 | if strings.Count(out, "\n") != 2 { | |
| 134 | t.Fatalf("issues duplicated:\n%s", out) | |
| 135 | } | |
| 136 | ||
| 137 | // A wrong token surfaces the API error. | |
| 138 | if _, errOut, code := inst.ssh(t, aliceKey, "wrong\n", "repo", "import-issues", "alice/app", | |
| 139 | "--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL); code == 0 || !strings.Contains(errOut, "401") { | |
| 140 | t.Fatalf("bad token: exit %d, %s", code, errOut) | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | func TestGitHubImportSSRFGuard(t *testing.T) { | |
| 145 | inst := startInstance(t) // allow_local off: default posture | |
| 146 | aliceKey := inst.newKey(t, "alice") | |
| 147 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 148 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 149 | t.Fatal("repo create failed") | |
| 150 | } | |
| 151 | _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app", | |
| 152 | "--from", "octo/legacy", "--api-base", "http://127.0.0.1:9999") | |
| 153 | if code != 2 || !strings.Contains(errOut, "SSRF") { | |
| 154 | t.Fatalf("local api-base allowed: exit %d, %s", code, errOut) | |
| 155 | } | |
| 156 | } | |
internal/control/ghimport.go added +290
| @@ -0,0 +1,290 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bufio" | |
| 5 | "encoding/json" | |
| 6 | "fmt" | |
| 7 | "io" | |
| 8 | "net/http" | |
| 9 | "net/url" | |
| 10 | "strconv" | |
| 11 | "strings" | |
| 12 | "time" | |
| 13 | ||
| 14 | "gitbay.org/gitbay/internal/gitutil" | |
| 15 | "gitbay.org/gitbay/internal/policy" | |
| 16 | "gitbay.org/gitbay/internal/protocol" | |
| 17 | "gitbay.org/gitbay/internal/store" | |
| 18 | "gitbay.org/gitbay/internal/webhook" | |
| 19 | ) | |
| 20 | ||
| 21 | func init() { | |
| 22 | register(Command{Path: []string{"repo", "import-issues"}, | |
| 23 | Summary: "import GitHub issue and PR history: repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin] [--api-base <url>]", | |
| 24 | ReadsStdin: true, Run: runImportIssues}) | |
| 25 | } | |
| 26 | ||
| 27 | // GitHub API shapes, minimal. | |
| 28 | type ghUser struct { | |
| 29 | Login string `json:"login"` | |
| 30 | } | |
| 31 | type ghIssue struct { | |
| 32 | Number int64 `json:"number"` | |
| 33 | Title string `json:"title"` | |
| 34 | Body string `json:"body"` | |
| 35 | State string `json:"state"` | |
| 36 | CreatedAt string `json:"created_at"` | |
| 37 | User ghUser `json:"user"` | |
| 38 | Labels []struct{ Name string } `json:"labels"` | |
| 39 | PullRequest *struct{} `json:"pull_request"` | |
| 40 | Comments int `json:"comments"` | |
| 41 | } | |
| 42 | type ghPull struct { | |
| 43 | MergedAt string `json:"merged_at"` | |
| 44 | Head struct { | |
| 45 | SHA string `json:"sha"` | |
| 46 | Ref string `json:"ref"` | |
| 47 | } `json:"head"` | |
| 48 | Base struct { | |
| 49 | SHA string `json:"sha"` | |
| 50 | Ref string `json:"ref"` | |
| 51 | } `json:"base"` | |
| 52 | } | |
| 53 | type ghComment struct { | |
| 54 | ID int64 `json:"id"` | |
| 55 | Body string `json:"body"` | |
| 56 | CreatedAt string `json:"created_at"` | |
| 57 | User ghUser `json:"user"` | |
| 58 | } | |
| 59 | ||
| 60 | type ghClient struct { | |
| 61 | base string | |
| 62 | token string | |
| 63 | http *http.Client | |
| 64 | } | |
| 65 | ||
| 66 | func (g *ghClient) get(path string, out any) error { | |
| 67 | req, err := http.NewRequest("GET", g.base+path, nil) | |
| 68 | if err != nil { | |
| 69 | return err | |
| 70 | } | |
| 71 | req.Header.Set("Accept", "application/vnd.github+json") | |
| 72 | if g.token != "" { | |
| 73 | req.Header.Set("Authorization", "Bearer "+g.token) | |
| 74 | } | |
| 75 | resp, err := g.http.Do(req) | |
| 76 | if err != nil { | |
| 77 | return err | |
| 78 | } | |
| 79 | defer resp.Body.Close() | |
| 80 | if resp.StatusCode != 200 { | |
| 81 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) | |
| 82 | return fmt.Errorf("GitHub API %s: %s: %.200s", path, resp.Status, body) | |
| 83 | } | |
| 84 | return json.NewDecoder(resp.Body).Decode(out) | |
| 85 | } | |
| 86 | ||
| 87 | func ghDate(iso string) string { | |
| 88 | if t, err := time.Parse(time.RFC3339, iso); err == nil { | |
| 89 | return t.UTC().Format("2006-01-02") | |
| 90 | } | |
| 91 | return iso | |
| 92 | } | |
| 93 | ||
| 94 | // attribution heads every imported body: foreign authors have no local | |
| 95 | // account, so the original author and date live in the text. | |
| 96 | func attribution(src string, n int64, kind, login, date string) string { | |
| 97 | return fmt.Sprintf("> imported %s %s#%d — @%s, %s\n\n", kind, src, n, login, ghDate(date)) | |
| 98 | } | |
| 99 | ||
| 100 | func runImportIssues(c *Ctx, args []string) int { | |
| 101 | var path, from, apiBase string | |
| 102 | tokenStdin := false | |
| 103 | for i := 0; i < len(args); i++ { | |
| 104 | switch args[i] { | |
| 105 | case "--from", "--api-base": | |
| 106 | if i+1 >= len(args) { | |
| 107 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | |
| 108 | } | |
| 109 | if args[i] == "--from" { | |
| 110 | from = args[i+1] | |
| 111 | } else { | |
| 112 | apiBase = args[i+1] | |
| 113 | } | |
| 114 | i++ | |
| 115 | case "--token-stdin": | |
| 116 | tokenStdin = true | |
| 117 | default: | |
| 118 | if path != "" { | |
| 119 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | |
| 120 | } | |
| 121 | path = args[i] | |
| 122 | } | |
| 123 | } | |
| 124 | if path == "" || from == "" { | |
| 125 | return c.fail(protocol.ExitUsage, "usage: repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin]") | |
| 126 | } | |
| 127 | // Accept a bare owner/repo or a full github.com URL. | |
| 128 | from = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(from, "https://"), "github.com/"), ".git") | |
| 129 | if parts := strings.Split(from, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" { | |
| 130 | return c.fail(protocol.ExitUsage, "--from must be <ghowner>/<ghrepo> (or the github.com URL)") | |
| 131 | } | |
| 132 | if apiBase == "" { | |
| 133 | apiBase = "https://api.github.com" | |
| 134 | } else if err := webhook.ValidateURL(apiBase, c.Cfg.Webhooks.AllowLocal); err != nil { | |
| 135 | // A writer-supplied API base is the same SSRF surface as a | |
| 136 | // webhook target; same rules apply. | |
| 137 | return c.fail(protocol.ExitUsage, "--api-base: %v", err) | |
| 138 | } | |
| 139 | repo, code := resolveRepo(c, path, policy.CanWrite) | |
| 140 | if code >= 0 { | |
| 141 | return code | |
| 142 | } | |
| 143 | if code := refuseArchived(c, repo); code >= 0 { | |
| 144 | return code | |
| 145 | } | |
| 146 | token := "" | |
| 147 | if tokenStdin { | |
| 148 | // Same discipline as repo import: token on stdin, never argv, | |
| 149 | // never stored. | |
| 150 | line, err := bufio.NewReader(c.Stdin).ReadString('\n') | |
| 151 | if err != nil && line == "" { | |
| 152 | return c.fail(protocol.ExitUsage, "--token-stdin: no token on stdin") | |
| 153 | } | |
| 154 | token = strings.TrimSpace(line) | |
| 155 | } | |
| 156 | g := &ghClient{base: apiBase, token: token, http: &http.Client{Timeout: 30 * time.Second}} | |
| 157 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 158 | src := "github.com/" + from | |
| 159 | ||
| 160 | var issues, mrs, comments, skipped int | |
| 161 | for page := 1; ; page++ { | |
| 162 | var items []ghIssue | |
| 163 | q := fmt.Sprintf("/repos/%s/issues?state=all&sort=created&direction=asc&per_page=100&page=%d", from, page) | |
| 164 | if err := g.get(q, &items); err != nil { | |
| 165 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 166 | } | |
| 167 | if len(items) == 0 { | |
| 168 | break | |
| 169 | } | |
| 170 | for _, it := range items { | |
| 171 | key := fmt.Sprintf("gh:%d", it.Number) | |
| 172 | val, seen, err := c.Store.ImportMarker(repo.ID, key) | |
| 173 | if err != nil { | |
| 174 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 175 | } | |
| 176 | var localN int64 | |
| 177 | isPR := it.PullRequest != nil | |
| 178 | if seen { | |
| 179 | localN, _ = strconv.ParseInt(strings.TrimPrefix(strings.TrimPrefix(val, "issue:"), "mr:"), 10, 64) | |
| 180 | skipped++ | |
| 181 | } else if isPR { | |
| 182 | var pr ghPull | |
| 183 | if err := g.get(fmt.Sprintf("/repos/%s/pulls/%d", from, it.Number), &pr); err != nil { | |
| 184 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 185 | } | |
| 186 | body := attribution(src, it.Number, "pull request", it.User.Login, it.CreatedAt) + it.Body | |
| 187 | localN, err = c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, pr.Head.Ref, pr.Base.Ref, it.Title, body, pr.Head.SHA) | |
| 188 | if err != nil { | |
| 189 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 190 | } | |
| 191 | mr, err := c.Store.MRByNumber(repo.ID, localN) | |
| 192 | if err != nil { | |
| 193 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 194 | } | |
| 195 | if pr.MergedAt != "" { | |
| 196 | c.Store.MarkMerged(mr.ID, pr.Base.SHA) | |
| 197 | } else { | |
| 198 | c.Store.SetMRState(mr.ID, "closed") | |
| 199 | } | |
| 200 | // Point the MR head ref at the PR head when the mirror | |
| 201 | // already holds the objects (refs/pull backups). | |
| 202 | if pr.Head.SHA != "" && gitutil.HasCommit(dir, pr.Head.SHA) { | |
| 203 | gitutil.UpdateRefCAS(dir, fmt.Sprintf("refs/merge-requests/%d/head", localN), pr.Head.SHA, "") | |
| 204 | } | |
| 205 | c.Store.SetImportMarker(repo.ID, key, fmt.Sprintf("mr:%d", localN)) | |
| 206 | mrs++ | |
| 207 | } else { | |
| 208 | body := attribution(src, it.Number, "issue", it.User.Login, it.CreatedAt) + it.Body | |
| 209 | localN, err = c.Store.CreateIssue(repo.ID, c.User.ID, it.Title, body) | |
| 210 | if err != nil { | |
| 211 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 212 | } | |
| 213 | iss, err := c.Store.IssueByNumber(repo.ID, localN) | |
| 214 | if err != nil { | |
| 215 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 216 | } | |
| 217 | for _, l := range it.Labels { | |
| 218 | c.Store.SetIssueLabel(repo.ID, iss.ID, l.Name, true) | |
| 219 | } | |
| 220 | if it.State != "open" { | |
| 221 | c.Store.SetIssueState(iss.ID, "closed") | |
| 222 | } | |
| 223 | c.Store.SetImportMarker(repo.ID, key, fmt.Sprintf("issue:%d", localN)) | |
| 224 | issues++ | |
| 225 | } | |
| 226 | if it.Comments > 0 && localN > 0 { | |
| 227 | n, err := importComments(c, g, repo, from, src, it.Number, localN, isPR) | |
| 228 | if err != nil { | |
| 229 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 230 | } | |
| 231 | comments += n | |
| 232 | } | |
| 233 | fmt.Fprintf(c.Stderr, "%s#%d -> %s%d\n", src, it.Number, map[bool]string{true: "!", false: "#"}[isPR], localN) | |
| 234 | } | |
| 235 | } | |
| 236 | d := map[string]any{"issues": issues, "mrs": mrs, "comments": comments, "already_imported": skipped} | |
| 237 | return c.emit(d, func(w io.Writer) { | |
| 238 | fmt.Fprintf(w, "imported %d issues, %d merge requests, %d comments (%d items already imported)\n", | |
| 239 | issues, mrs, comments, skipped) | |
| 240 | }) | |
| 241 | } | |
| 242 | ||
| 243 | func importComments(c *Ctx, g *ghClient, repo store.Repo, from, src string, ghN, localN int64, isPR bool) (int, error) { | |
| 244 | var localIssueID, localMRID int64 | |
| 245 | if isPR { | |
| 246 | mr, err := c.Store.MRByNumber(repo.ID, localN) | |
| 247 | if err != nil { | |
| 248 | return 0, err | |
| 249 | } | |
| 250 | localMRID = mr.ID | |
| 251 | } else { | |
| 252 | iss, err := c.Store.IssueByNumber(repo.ID, localN) | |
| 253 | if err != nil { | |
| 254 | return 0, err | |
| 255 | } | |
| 256 | localIssueID = iss.ID | |
| 257 | } | |
| 258 | imported := 0 | |
| 259 | for page := 1; ; page++ { | |
| 260 | var cs []ghComment | |
| 261 | q := fmt.Sprintf("/repos/%s/issues/%d/comments?per_page=100&page=%d", url.PathEscape(from), ghN, page) | |
| 262 | q = strings.ReplaceAll(q, "%2F", "/") | |
| 263 | if err := g.get(q, &cs); err != nil { | |
| 264 | return imported, err | |
| 265 | } | |
| 266 | if len(cs) == 0 { | |
| 267 | return imported, nil | |
| 268 | } | |
| 269 | for _, cm := range cs { | |
| 270 | key := fmt.Sprintf("ghc:%d", cm.ID) | |
| 271 | if _, seen, err := c.Store.ImportMarker(repo.ID, key); err != nil { | |
| 272 | return imported, err | |
| 273 | } else if seen { | |
| 274 | continue | |
| 275 | } | |
| 276 | body := fmt.Sprintf("> @%s, %s\n\n%s", cm.User.Login, ghDate(cm.CreatedAt), cm.Body) | |
| 277 | var err error | |
| 278 | if isPR { | |
| 279 | err = c.Store.AddMRComment(localMRID, c.User.ID, body) | |
| 280 | } else { | |
| 281 | err = c.Store.AddIssueComment(localIssueID, c.User.ID, body) | |
| 282 | } | |
| 283 | if err != nil { | |
| 284 | return imported, err | |
| 285 | } | |
| 286 | c.Store.SetImportMarker(repo.ID, key, "") | |
| 287 | imported++ | |
| 288 | } | |
| 289 | } | |
| 290 | } | |
internal/gitutil/messages.go +5
| @@ -8,6 +8,11 @@ import ( | ||
| 8 | 8 | |
| 9 | 9 | const zeroSHA = "0000000000000000000000000000000000000000" |
| 10 | 10 | |
| 11 | // HasCommit reports whether sha names a commit object present in dir. | |
| 12 | func HasCommit(dir, sha string) bool { | |
| 13 | return exec.Command("git", "-C", dir, "cat-file", "-e", sha+"^{commit}").Run() == nil | |
| 14 | } | |
| 15 | ||
| 11 | 16 | type CommitMsg struct { |
| 12 | 17 | SHA string |
| 13 | 18 | Message string |
internal/store/importmarkers.go added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | ) | |
| 7 | ||
| 8 | // ImportMarker returns the stored value for an import progress key, and | |
| 9 | // whether it exists. Markers make history imports resumable: items and | |
| 10 | // comments already imported are skipped on re-run. | |
| 11 | func (s *Store) ImportMarker(repoID int64, key string) (string, bool, error) { | |
| 12 | var v string | |
| 13 | err := s.DB.QueryRow( | |
| 14 | "SELECT value FROM import_markers WHERE repo_id = ? AND key = ?", repoID, key).Scan(&v) | |
| 15 | if errors.Is(err, sql.ErrNoRows) { | |
| 16 | return "", false, nil | |
| 17 | } | |
| 18 | return v, err == nil, err | |
| 19 | } | |
| 20 | ||
| 21 | func (s *Store) SetImportMarker(repoID int64, key, value string) error { | |
| 22 | _, err := s.DB.Exec( | |
| 23 | "INSERT INTO import_markers (repo_id, key, value) VALUES (?, ?, ?) ON CONFLICT (repo_id, key) DO UPDATE SET value = excluded.value", | |
| 24 | repoID, key, value) | |
| 25 | return err | |
| 26 | } | |
internal/store/migrations/0016_import_markers.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP TABLE import_markers; | |
internal/store/migrations/0016_import_markers.up.sql added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | CREATE TABLE import_markers ( | |
| 2 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 3 | key TEXT NOT NULL, | |
| 4 | value TEXT NOT NULL DEFAULT '', | |
| 5 | PRIMARY KEY (repo_id, key) | |
| 6 | ); | |