From 03058691c3cec36995d7e8cdcddd3aa1c6274155 Mon Sep 17 00:00:00 2001 From: Jon Fuller Date: Thu, 24 Sep 2026 18:23:50 -0700 Subject: [PATCH] fix(migrations): stop endless comment paging when migrating from Gitea (#39419) `GET /repos/{owner}/{repo}/issues/{index}/comments` ignores `page` and `limit` and always returns every comment. The Gitea downloader pages it and stops only on a short page, so migrating from Gitea or Forgejo loops forever on any issue with at least `MAX_RESPONSE_ITEMS` comments, without an error. Paging is kept in case the endpoint gets paginated (https://github.com/go-gitea/gitea/issues/6132, https://github.com/go-gitea/gitea/issues/18082). The loop now stops when a page is longer than the limit or starts with an already seen comment. Prior art: Forgejo fixed its copy in https://codeberg.org/forgejo/forgejo/pulls/9274 (report: https://codeberg.org/Codeberg/Community/issues/1542). --- AI-assisted: drafted with Claude Code (claude-opus-5-5), reviewed by me. Please let me know if you have any suggestions or comments, I ran into this issue myself when I was trying to migrate repositories from Forgejo -> Gitea. --------- Co-authored-by: silverwind --- services/migrations/gitea_downloader.go | 8 ++- services/migrations/gitea_downloader_test.go | 51 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/services/migrations/gitea_downloader.go b/services/migrations/gitea_downloader.go index 6ec70e2bbb0..60bff07434b 100644 --- a/services/migrations/gitea_downloader.go +++ b/services/migrations/gitea_downloader.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "gitea.dev/modules/container" "gitea.dev/modules/log" base "gitea.dev/modules/migration" "gitea.dev/modules/structs" @@ -472,6 +473,7 @@ func (g *GiteaDownloader) GetIssues(ctx context.Context, page, perPage int) ([]* // GetComments returns comments according issueNumber func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) { allComments := make([]*base.Comment, 0, g.maxPerPage) + seenIDs := container.Set[int64]{} for i := 1; ; i++ { // make sure gitea can shutdown gracefully @@ -488,8 +490,12 @@ func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Comm if err != nil { return nil, false, fmt.Errorf("error while listing comments for issue #%d. Error: %w", commentable.GetForeignIndex(), err) } + if len(comments) == 0 || seenIDs.Contains(comments[0].ID) { + break // the endpoint ignores page and limit, so page 2 repeats page 1 + } for _, comment := range comments { + seenIDs.Add(comment.ID) reactions, err := g.getCommentReactions(comment.ID) if err != nil { WarnAndNotice("Unable to load comment reactions during migrating issue #%d for comment %d in %s. Error: %v", commentable.GetForeignIndex(), comment.ID, g, err) @@ -508,7 +514,7 @@ func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Comm }) } - if !g.pagination || len(comments) < g.maxPerPage { + if !g.pagination || len(comments) != g.maxPerPage { break } } diff --git a/services/migrations/gitea_downloader_test.go b/services/migrations/gitea_downloader_test.go index e65aa024ee2..4b1dbdf22bd 100644 --- a/services/migrations/gitea_downloader_test.go +++ b/services/migrations/gitea_downloader_test.go @@ -4,10 +4,15 @@ package migrations import ( + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "sort" + "strconv" + "strings" "testing" "time" @@ -309,3 +314,49 @@ func TestGiteaDownloadRepo(t *testing.T) { }, }, reviews) } + +func TestGiteaDownloadCommentsPaging(t *testing.T) { + for _, tc := range []struct { + maxResponseItems, commentCount, requests int + paginated bool + }{ + {maxResponseItems: 2, commentCount: 2, requests: 2}, + {maxResponseItems: 2, commentCount: 3, requests: 1}, + {maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true}, + {maxResponseItems: 0, commentCount: 0, requests: 1}, + } { + t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) { + commentRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + _, _ = w.Write([]byte(`{"version":"1.27.0"}`)) + case "/api/v1/settings/api": + _, _ = fmt.Fprintf(w, `{"max_response_items":%d}`, tc.maxResponseItems) + case "/api/v1/repos/o/r/issues/1/comments": + commentRequests++ + comments := make([]string, tc.commentCount) + for i := range comments { + comments[i] = fmt.Sprintf(`{"id":%d,"user":{}}`, i+1) + } + if tc.paginated { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + comments = comments[(page-1)*tc.maxResponseItems : min(page*tc.maxResponseItems, len(comments))] + } + _, _ = w.Write([]byte("[" + strings.Join(comments, ",") + "]")) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + defer server.Close() + + downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "") + require.NoError(t, err) + + comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1}) + require.NoError(t, err) + assert.Len(t, comments, tc.commentCount) + assert.Equal(t, tc.requests, commentRequests) + }) + } +}