Commit bc93d6cbca
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/FAQ.org +3 −3
| @@ -9,9 +9,9 @@ | ||
| 9 | 9 | when the one below merges, and refuses squash or rebase under a stack. |
| 10 | 10 | No stack command; the workflow is branches. See |
| 11 | 11 | [[Stacked-MRs][Stacked merge requests]]. |
| 12 | - Moving from GitHub? :: =gitbay repo import= (git data) then | |
| 13 | =gitbay repo import-issues= (history); =repo mirror= covers the | |
| 14 | transition window. | |
| 12 | - Moving from GitHub or Codeberg? :: =gitbay repo import= (git data) | |
| 13 | then =gitbay repo import-issues= (history, from GitHub or any Forgejo | |
| 14 | =--api-base=); =repo mirror= covers the transition window. | |
| 15 | 15 | - What is not planned? :: A container or package registry (scope creep |
| 16 | 16 | away from "forge"; external registries integrate through CI), an |
| 17 | 17 | email patch flow (revisit only if sourcehut-style demand appears), |
.gitbay/wiki/Users.org +10 −5
| @@ -207,17 +207,22 @@ gitbay repo archive you/project # read-only: pushes and issu | ||
| 207 | 207 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 208 | 208 | #+end_src |
| 209 | 209 | |
| 210 | Import from another forge — git data first, then optionally the GitHub | |
| 211 | issue and PR history (issues keep state/labels/comments; PRs land as | |
| 212 | closed or merged MRs with their discussion; originals are attributed | |
| 213 | inline since foreign authors have no local account; re-running resumes | |
| 214 | where it stopped): | |
| 210 | Import from another forge — git data first, then optionally the issue | |
| 211 | and PR history from GitHub or any Forgejo instance such as Codeberg | |
| 212 | (issues keep state/labels/comments; PRs land as closed or merged MRs | |
| 213 | with their discussion; originals are attributed inline since foreign | |
| 214 | authors have no local account; re-running resumes where it stopped): | |
| 215 | 215 | #+begin_src sh |
| 216 | 216 | gitbay repo import you/mirror --from https://github.com/you/repo.git \ |
| 217 | 217 | [--private] [--token-stdin] # token on stdin, never in the URL |
| 218 | 218 | gitbay repo import-issues you/mirror --from you/repo --token-stdin |
| 219 | gitbay repo import-issues you/mirror --from you/repo \ | |
| 220 | --api-base https://codeberg.org/api/v1 # Forgejo: the site's /api/v1 | |
| 219 | 221 | #+end_src |
| 220 | 222 | |
| 223 | Sourcehut has no API of that shape; =repo import= takes its git data | |
| 224 | and the todo.sr.ht tracker is not read. | |
| 225 | ||
| 221 | 226 | Moving between gitbay instances (no lock-in): run on the TARGET, with |
| 222 | 227 | your key registered on both sides. Profile, repos with settings, |
| 223 | 228 | issues, MRs, and comments replay with attribution; git data mirrors |
e2e/ghimport_test.go +111 −2
| @@ -92,6 +92,7 @@ func TestGitHubIssueImport(t *testing.T) { | ||
| 92 | 92 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 93 | 93 | |
| 94 | 94 | gh := fakeGitHub(t) |
| 95 | host := strings.TrimPrefix(gh.URL, "http://") | |
| 95 | 96 | out, errOut, code := inst.ssh(t, aliceKey, "sekrit\n", "repo", "import-issues", "alice/app", |
| 96 | 97 | "--from", "octo/legacy", "--token-stdin", "--api-base", gh.URL) |
| 97 | 98 | if code != 0 { |
| @@ -105,7 +106,7 @@ func TestGitHubIssueImport(t *testing.T) { | ||
| 105 | 106 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") |
| 106 | 107 | if !strings.Contains(out, "old bug") || !strings.Contains(out, `"state":"closed"`) || |
| 107 | 108 | !strings.Contains(out, `"labels":["bug"]`) || |
| 108 | !strings.Contains(out, "imported issue github.com/octo/legacy#1") || | |
| 109 | !strings.Contains(out, "imported issue "+host+"/octo/legacy#1") || | |
| 109 | 110 | !strings.Contains(out, "@octofan, 2019-03-04") { |
| 110 | 111 | t.Fatalf("issue 1: %s", out) |
| 111 | 112 | } |
| @@ -118,7 +119,7 @@ func TestGitHubIssueImport(t *testing.T) { | ||
| 118 | 119 | // MR !1 (GitHub PR #2): merged, discussion imported. |
| 119 | 120 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json") |
| 120 | 121 | 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, "imported pull request "+host+"/octo/legacy#2") || | |
| 122 | 123 | !strings.Contains(out, "nice patch") { |
| 123 | 124 | t.Fatalf("mr 1: %s", out) |
| 124 | 125 | } |
| @@ -154,3 +155,111 @@ func TestGitHubImportSSRFGuard(t *testing.T) { | ||
| 154 | 155 | t.Fatalf("local api-base allowed: exit %d, %s", code, errOut) |
| 155 | 156 | } |
| 156 | 157 | } |
| 158 | ||
| 159 | // fakeForgejo serves the Forgejo shape of the same API under /api/v1: | |
| 160 | // GitHub's issue, pull and comment objects, but pages sized by `limit`, | |
| 161 | // order by `sort=oldest`, a /version endpoint, and a comments endpoint | |
| 162 | // that ignores `page` and returns everything every time. | |
| 163 | func fakeForgejo(t *testing.T) *httptest.Server { | |
| 164 | t.Helper() | |
| 165 | mux := http.NewServeMux() | |
| 166 | mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { | |
| 167 | fmt.Fprint(w, `{"version":"9.0.0+gitea-1.22.0"}`) | |
| 168 | }) | |
| 169 | mux.HandleFunc("/api/v1/repos/octo/legacy/issues", func(w http.ResponseWriter, r *http.Request) { | |
| 170 | q := r.URL.Query() | |
| 171 | if q.Get("page") != "1" { | |
| 172 | fmt.Fprint(w, "[]") | |
| 173 | return | |
| 174 | } | |
| 175 | items := []string{ | |
| 176 | `{"number":1,"title":"old bug","body":"it crashed","state":"closed", | |
| 177 | "created_at":"2019-03-04T10:00:00+01:00","user":{"login":"octofan"}, | |
| 178 | "labels":[{"name":"bug"}],"comments":0}`, | |
| 179 | `{"number":2,"title":"add feature","body":"the patch","state":"closed", | |
| 180 | "created_at":"2020-06-01T10:00:00Z","user":{"login":"drive-by"}, | |
| 181 | "labels":[],"comments":1,"pull_request":{"merged":true}}`, | |
| 182 | `{"number":3,"title":"still open","body":"discuss","state":"open", | |
| 183 | "created_at":"2021-01-01T10:00:00Z","user":{"login":"octofan"}, | |
| 184 | "labels":[],"comments":2}`, | |
| 185 | } | |
| 186 | // Forgejo's default is newest first; only sort=oldest gives | |
| 187 | // the order local numbering depends on. | |
| 188 | if q.Get("sort") != "oldest" || q.Get("limit") == "" { | |
| 189 | items[0], items[2] = items[2], items[0] | |
| 190 | } | |
| 191 | fmt.Fprint(w, "["+strings.Join(items, ",")+"]") | |
| 192 | }) | |
| 193 | mux.HandleFunc("/api/v1/repos/octo/legacy/pulls/2", func(w http.ResponseWriter, r *http.Request) { | |
| 194 | fmt.Fprint(w, `{"merged_at":"2020-06-02T10:00:00Z", | |
| 195 | "head":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref":"feature"}, | |
| 196 | "base":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","ref":"main"}}`) | |
| 197 | }) | |
| 198 | comments := func(payload string) http.HandlerFunc { | |
| 199 | return func(w http.ResponseWriter, r *http.Request) { | |
| 200 | // No paging on this endpoint: the real one ignores `page` | |
| 201 | // and returns everything, so a caller walking pages never | |
| 202 | // stops. Answer a second page with an error so the test | |
| 203 | // fails instead of hanging. | |
| 204 | if p := r.URL.Query().Get("page"); p != "" && p != "1" { | |
| 205 | http.Error(w, "unpaged endpoint asked for page "+p, 500) | |
| 206 | return | |
| 207 | } | |
| 208 | fmt.Fprint(w, payload) | |
| 209 | } | |
| 210 | } | |
| 211 | mux.HandleFunc("/api/v1/repos/octo/legacy/issues/2/comments", comments( | |
| 212 | `[{"id":101,"body":"nice patch","created_at":"2020-06-01T11:00:00Z","user":{"login":"maintainer"}}]`)) | |
| 213 | mux.HandleFunc("/api/v1/repos/octo/legacy/issues/3/comments", comments( | |
| 214 | `[{"id":102,"body":"me too","created_at":"2021-01-02T10:00:00Z","user":{"login":"other"}}, | |
| 215 | {"id":103,"body":"still happening","created_at":"2021-02-01T10:00:00Z","user":{"login":"octofan"}}]`)) | |
| 216 | srv := httptest.NewServer(mux) | |
| 217 | t.Cleanup(srv.Close) | |
| 218 | return srv | |
| 219 | } | |
| 220 | ||
| 221 | func TestForgejoIssueImport(t *testing.T) { | |
| 222 | inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n") | |
| 223 | aliceKey := inst.newKey(t, "alice") | |
| 224 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 225 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 226 | t.Fatalf("repo create: %s", errOut) | |
| 227 | } | |
| 228 | fj := fakeForgejo(t) | |
| 229 | host := strings.TrimPrefix(fj.URL, "http://") | |
| 230 | // --from as the repository's URL on the site, the way a Codeberg | |
| 231 | // user copies it from the address bar. | |
| 232 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app", | |
| 233 | "--from", fj.URL+"/octo/legacy", "--api-base", fj.URL+"/api/v1") | |
| 234 | if code != 0 { | |
| 235 | t.Fatalf("import: %s", errOut) | |
| 236 | } | |
| 237 | if !strings.Contains(out, "imported 2 issues, 1 merge requests, 3 comments") { | |
| 238 | t.Fatalf("summary: %s", out) | |
| 239 | } | |
| 240 | // Oldest first, attributed to the site the API base belongs to. | |
| 241 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | |
| 242 | if !strings.Contains(out, "old bug") || !strings.Contains(out, `"state":"closed"`) || | |
| 243 | !strings.Contains(out, `"labels":["bug"]`) || | |
| 244 | !strings.Contains(out, "imported issue "+host+"/octo/legacy#1") || | |
| 245 | !strings.Contains(out, "@octofan, 2019-03-04") { | |
| 246 | t.Fatalf("issue 1: %s", out) | |
| 247 | } | |
| 248 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") | |
| 249 | if !strings.Contains(out, "still open") || !strings.Contains(out, "me too") || | |
| 250 | !strings.Contains(out, "still happening") { | |
| 251 | t.Fatalf("issue 2: %s", out) | |
| 252 | } | |
| 253 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json") | |
| 254 | if !strings.Contains(out, "add feature") || !strings.Contains(out, `"state":"merged"`) || | |
| 255 | !strings.Contains(out, "imported pull request "+host+"/octo/legacy#2") || | |
| 256 | !strings.Contains(out, "nice patch") { | |
| 257 | t.Fatalf("mr 1: %s", out) | |
| 258 | } | |
| 259 | // Re-running imports nothing new. | |
| 260 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "import-issues", "alice/app", | |
| 261 | "--from", "octo/legacy", "--api-base", fj.URL+"/api/v1") | |
| 262 | if code != 0 || !strings.Contains(out, "imported 0 issues, 0 merge requests, 0 comments (3 items already imported)") { | |
| 263 | t.Fatalf("re-run: %s", out) | |
| 264 | } | |
| 265 | } | |
internal/control/ghimport.go +65 −19
| @@ -23,8 +23,8 @@ import ( | ||
| 23 | 23 | |
| 24 | 24 | func init() { |
| 25 | 25 | register(Command{Path: []string{"repo", "import-issues"}, |
| 26 | Summary: "import GitHub issue and PR history", | |
| 27 | Usage: "repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin] [--api-base <url>]", | |
| 26 | Summary: "import issue and PR history from GitHub or Forgejo", | |
| 27 | Usage: "repo import-issues <owner/name> --from <owner/repo> [--token-stdin] [--api-base <url>]", | |
| 28 | 28 | ReadsStdin: true, Run: runImportIssues}) |
| 29 | 29 | } |
| 30 | 30 | |
| @@ -66,6 +66,45 @@ type ghClient struct { | ||
| 66 | 66 | base string |
| 67 | 67 | token string |
| 68 | 68 | http *http.Client |
| 69 | // forgejo is set when the API base answers /version, which GitHub | |
| 70 | // does not. Forgejo (and Gitea, and so Codeberg) mirror GitHub's | |
| 71 | // issue, pull and comment shapes but not its query parameters: | |
| 72 | // pages are sized by `limit`, order is `sort=oldest`, and the | |
| 73 | // comments endpoint has no pages at all — it ignores `page` and | |
| 74 | // returns everything every time, which paged the old loop forever. | |
| 75 | forgejo bool | |
| 76 | } | |
| 77 | ||
| 78 | func (g *ghClient) detect() { | |
| 79 | var v struct{ Version string } | |
| 80 | g.forgejo = g.get("/version", &v) == nil && v.Version != "" | |
| 81 | } | |
| 82 | ||
| 83 | // issuesQuery lists every issue and pull request oldest first, so local | |
| 84 | // numbers come out in the source's order. | |
| 85 | func (g *ghClient) issuesQuery(from string, page int) string { | |
| 86 | if g.forgejo { | |
| 87 | return fmt.Sprintf("/repos/%s/issues?state=all&sort=oldest&limit=50&page=%d", from, page) | |
| 88 | } | |
| 89 | return fmt.Sprintf("/repos/%s/issues?state=all&sort=created&direction=asc&per_page=100&page=%d", from, page) | |
| 90 | } | |
| 91 | ||
| 92 | // siteFromAPI turns an API base into the site that serves git and the | |
| 93 | // name attribution carries: api.github.com is github.com, a GitHub | |
| 94 | // Enterprise or Forgejo base drops its /api/vN suffix. | |
| 95 | func siteFromAPI(apiBase string) string { | |
| 96 | u, err := url.Parse(apiBase) | |
| 97 | if err != nil { | |
| 98 | return apiBase | |
| 99 | } | |
| 100 | if u.Host == "api.github.com" { | |
| 101 | u.Host = "github.com" | |
| 102 | u.Path = "" | |
| 103 | } | |
| 104 | u.Path = strings.TrimSuffix(strings.TrimSuffix(u.Path, "/"), "/api/v1") | |
| 105 | u.Path = strings.TrimSuffix(u.Path, "/api/v3") | |
| 106 | u.RawQuery, u.Fragment = "", "" | |
| 107 | return u.String() | |
| 69 | 108 | } |
| 70 | 109 | |
| 71 | 110 | func (g *ghClient) get(path string, out any) error { |
| @@ -110,12 +149,7 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 110 | 149 | } |
| 111 | 150 | path, from, apiBase, tokenStdin := f.pos(0), f.Value("--from"), f.Value("--api-base"), f.Has("--token-stdin") |
| 112 | 151 | if path == "" || from == "" { |
| 113 | return c.fail(protocol.ExitUsage, "usage: repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin]") | |
| 114 | } | |
| 115 | // Accept a bare owner/repo or a full github.com URL. | |
| 116 | from = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(from, "https://"), "github.com/"), ".git") | |
| 117 | if parts := strings.Split(from, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" { | |
| 118 | return c.fail(protocol.ExitUsage, "--from must be <ghowner>/<ghrepo> (or the github.com URL)") | |
| 152 | return c.fail(protocol.ExitUsage, "usage: repo import-issues <owner/name> --from <owner/repo> [--token-stdin] [--api-base <url>]") | |
| 119 | 153 | } |
| 120 | 154 | if apiBase == "" { |
| 121 | 155 | apiBase = "https://api.github.com" |
| @@ -124,6 +158,15 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 124 | 158 | // webhook target; same rules apply. |
| 125 | 159 | return c.fail(protocol.ExitUsage, "--api-base: %v", err) |
| 126 | 160 | } |
| 161 | site := siteFromAPI(apiBase) | |
| 162 | host := strings.TrimPrefix(strings.TrimPrefix(site, "https://"), "http://") | |
| 163 | // Accept a bare owner/repo or the repository's URL on that site. | |
| 164 | from = strings.TrimPrefix(from, site+"/") | |
| 165 | from = strings.TrimPrefix(from, host+"/") | |
| 166 | from = strings.TrimSuffix(from, ".git") | |
| 167 | if parts := strings.Split(from, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" { | |
| 168 | return c.fail(protocol.ExitUsage, "--from must be <owner>/<repo> (or the repository URL on %s)", site) | |
| 169 | } | |
| 127 | 170 | repo, code := resolveRepo(c, path, policy.CanWrite) |
| 128 | 171 | if code >= 0 { |
| 129 | 172 | return code |
| @@ -142,6 +185,7 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 142 | 185 | token = strings.TrimSpace(line) |
| 143 | 186 | } |
| 144 | 187 | g := &ghClient{base: apiBase, token: token, http: &http.Client{Timeout: 30 * time.Second}} |
| 188 | g.detect() | |
| 145 | 189 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 146 | 190 | |
| 147 | 191 | // Pull heads first, so every merge request below has objects to point |
| @@ -151,14 +195,13 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 151 | 195 | // import of issues from a repository whose git data is not here yet |
| 152 | 196 | // is a legitimate thing to do, and the merge requests still arrive |
| 153 | 197 | // with their head SHA recorded. |
| 154 | fetchedPullHeads := fetchPullHeads(c, dir, from, token) | |
| 155 | src := "github.com/" + from | |
| 198 | fetchedPullHeads := fetchPullHeads(c, dir, site+"/"+from+".git", token) | |
| 199 | src := host + "/" + from | |
| 156 | 200 | |
| 157 | 201 | var issues, mrs, comments, skipped, headed int |
| 158 | 202 | for page := 1; ; page++ { |
| 159 | 203 | var items []ghIssue |
| 160 | q := fmt.Sprintf("/repos/%s/issues?state=all&sort=created&direction=asc&per_page=100&page=%d", from, page) | |
| 161 | if err := g.get(q, &items); err != nil { | |
| 204 | if err := g.get(g.issuesQuery(from, page), &items); err != nil { | |
| 162 | 205 | return c.fail(protocol.ExitFailure, "%v", err) |
| 163 | 206 | } |
| 164 | 207 | if len(items) == 0 { |
| @@ -262,6 +305,10 @@ func importComments(c *Ctx, g *ghClient, repo store.Repo, from, src string, ghN, | ||
| 262 | 305 | } |
| 263 | 306 | imported := 0 |
| 264 | 307 | for page := 1; ; page++ { |
| 308 | // Forgejo returns every comment in one unpaged reply. | |
| 309 | if g.forgejo && page > 1 { | |
| 310 | return imported, nil | |
| 311 | } | |
| 265 | 312 | var cs []ghComment |
| 266 | 313 | q := fmt.Sprintf("/repos/%s/issues/%d/comments?per_page=100&page=%d", url.PathEscape(from), ghN, page) |
| 267 | 314 | q = strings.ReplaceAll(q, "%2F", "/") |
| @@ -304,11 +351,11 @@ case "$1" in | ||
| 304 | 351 | esac |
| 305 | 352 | ` |
| 306 | 353 | |
| 307 | // fetchPullHeads brings refs/pull/*/head into refs/gh-pull/*. Reports | |
| 308 | // whether it worked; a failure is not fatal, since importing issues from | |
| 309 | // a repository whose git data is not here yet is a reasonable thing to | |
| 310 | // do. | |
| 311 | func fetchPullHeads(c *Ctx, dir, from, token string) bool { | |
| 354 | // fetchPullHeads brings refs/pull/*/head into refs/gh-pull/*; GitHub and | |
| 355 | // Forgejo both publish pull heads under that name. Reports whether it | |
| 356 | // worked; a failure is not fatal, since importing issues from a | |
| 357 | // repository whose git data is not here yet is a reasonable thing to do. | |
| 358 | func fetchPullHeads(c *Ctx, dir, remote, token string) bool { | |
| 312 | 359 | env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + c.Cfg.Server.Root} |
| 313 | 360 | if token != "" { |
| 314 | 361 | askpass := filepath.Join(c.Cfg.Server.Root, "gh-import-askpass.sh") |
| @@ -319,8 +366,7 @@ func fetchPullHeads(c *Ctx, dir, from, token string) bool { | ||
| 319 | 366 | } |
| 320 | 367 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| 321 | 368 | defer cancel() |
| 322 | url := "https://github.com/" + from + ".git" | |
| 323 | if err := gitutil.FetchPullHeads(ctx, dir, url, io.Discard, env); err != nil { | |
| 369 | if err := gitutil.FetchPullHeads(ctx, dir, remote, io.Discard, env); err != nil { | |
| 324 | 370 | return false |
| 325 | 371 | } |
| 326 | 372 | return true |