From 5bb8e617084858fe72cedd61b5afc5c0b5618245 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:58:12 +0000 Subject: [PATCH] feat(scm): add issue comment iterators Co-Authored-By: Virgil --- docs/architecture.md | 2 +- forge/issues.go | 26 +++++++++++++++++++++ forge/issues_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ forge/meta.go | 50 ++++++++++------------------------------ gitea/issues.go | 26 +++++++++++++++++++++ gitea/issues_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ gitea/meta.go | 50 ++++++++++------------------------------ 7 files changed, 185 insertions(+), 77 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7778996..e57015e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,7 +88,7 @@ The `gitea/` package mirrors this using `GITEA_URL`/`GITEA_TOKEN` and `gitea.*` |------|-----------| | `client.go` | `New`, `NewFromConfig`, `GetCurrentUser`, `ForkRepo`, `CreatePullRequest` | | `repos.go` | `ListOrgRepos`, `ListOrgReposIter`, `ListUserRepos`, `ListUserReposIter`, `GetRepo`, `CreateOrgRepo`, `DeleteRepo`, `MigrateRepo` | -| `issues.go` | `ListIssues`, `GetIssue`, `CreateIssue`, `EditIssue`, `AssignIssue`, `ListPullRequests`, `ListPullRequestsIter`, `GetPullRequest`, `CreateIssueComment`, `ListIssueComments`, `CloseIssue` | +| `issues.go` | `ListIssues`, `ListIssuesIter`, `GetIssue`, `CreateIssue`, `EditIssue`, `AssignIssue`, `ListPullRequests`, `ListPullRequestsIter`, `GetPullRequest`, `CreateIssueComment`, `ListIssueComments`, `ListIssueCommentsIter`, `CloseIssue` | | `labels.go` | `ListOrgLabels`, `ListRepoLabels`, `CreateRepoLabel`, `GetLabelByName`, `EnsureLabel`, `AddIssueLabels`, `RemoveIssueLabel` | | `prs.go` | `MergePullRequest`, `SetPRDraft`, `ListPRReviews`, `GetCombinedStatus`, `DismissReview` | | `webhooks.go` | `CreateRepoWebhook`, `ListRepoWebhooks` | diff --git a/forge/issues.go b/forge/issues.go index 2261c92..b540321 100644 --- a/forge/issues.go +++ b/forge/issues.go @@ -278,6 +278,32 @@ func (c *Client) ListIssueComments(owner, repo string, number int64) ([]*forgejo return all, nil } +// ListIssueCommentsIter returns an iterator over comments for an issue. +// Usage: ListIssueCommentsIter(...) +func (c *Client) ListIssueCommentsIter(owner, repo string, number int64) iter.Seq2[*forgejo.Comment, error] { + return func(yield func(*forgejo.Comment, error) bool) { + page := 1 + for { + comments, resp, err := c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if err != nil { + yield(nil, log.E("forge.ListIssueComments", "failed to list comments", err)) + return + } + for _, comment := range comments { + if !yield(comment, nil) { + return + } + } + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + } +} + // CloseIssue closes an issue by setting its state to closed. // Usage: CloseIssue(...) func (c *Client) CloseIssue(owner, repo string, number int64) error { diff --git a/forge/issues_test.go b/forge/issues_test.go index f0fa8de..b99cc81 100644 --- a/forge/issues_test.go +++ b/forge/issues_test.go @@ -5,6 +5,7 @@ package forge import ( "net/http" "net/http/httptest" + "strconv" "testing" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" @@ -41,6 +42,43 @@ func newPaginatedIssuesClient(t *testing.T) (*Client, *httptest.Server) { return client, srv } +func newPaginatedCommentsClient(t *testing.T) (*Client, *httptest.Server) { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "2": + jsonResponse(w, []map[string]any{ + {"id": 150, "body": "comment 51", "user": map[string]any{"login": "user51"}, "created_at": "2026-01-02T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"}, + }) + case "3": + jsonResponse(w, []map[string]any{}) + default: + w.Header().Set("Link", `; rel="next", ; rel="last"`) + comments := make([]map[string]any, 0, 50) + for i := 1; i <= 50; i++ { + comments = append(comments, map[string]any{ + "id": 99 + i, + "body": "comment " + strconv.Itoa(i), + "user": map[string]any{"login": "user" + strconv.Itoa(i)}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }) + } + jsonResponse(w, comments) + } + }) + + srv := httptest.NewServer(mux) + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + return client, srv +} + func TestClient_ListIssues_Good(t *testing.T) { client, srv := newTestClient(t) defer srv.Close() @@ -293,6 +331,22 @@ func TestClient_ListIssueComments_Bad_ServerError_Good(t *testing.T) { assert.Contains(t, err.Error(), "failed to list comments") } +func TestClient_ListIssueCommentsIter_Good_Paginates_Good(t *testing.T) { + client, srv := newPaginatedCommentsClient(t) + defer srv.Close() + + var bodies []string + for comment, err := range client.ListIssueCommentsIter("test-org", "org-repo", 1) { + require.NoError(t, err) + bodies = append(bodies, comment.Body) + } + + require.Len(t, bodies, 51) + assert.Equal(t, "comment 1", bodies[0]) + assert.Equal(t, "comment 50", bodies[49]) + assert.Equal(t, "comment 51", bodies[50]) +} + func TestClient_CloseIssue_Good(t *testing.T) { client, srv := newTestClient(t) defer srv.Close() diff --git a/forge/meta.go b/forge/meta.go index 4665f1d..ad51bcb 100644 --- a/forge/meta.go +++ b/forge/meta.go @@ -5,8 +5,6 @@ package forge import ( "time" - forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "dappco.re/go/core/log" ) @@ -78,19 +76,11 @@ func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { // Fetch comment count from the issue side (PRs are issues in Forgejo). // Paginate to get an accurate count. count := 0 - page := 1 - for { - comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, forgejo.ListIssueCommentOptions{ - ListOptions: forgejo.ListOptions{Page: page, PageSize: commentPageSize}, - }) - if listErr != nil { + for _, err := range c.ListIssueCommentsIter(owner, repo, pr) { + if err != nil { break } - count += len(comments) - if len(comments) < commentPageSize { - break - } - page++ + count++ } meta.CommentCount = count @@ -101,37 +91,21 @@ func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { // Usage: GetCommentBodies(...) func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) { var comments []Comment - page := 1 - - for { - raw, _, err := c.api.ListIssueComments(owner, repo, pr, forgejo.ListIssueCommentOptions{ - ListOptions: forgejo.ListOptions{Page: page, PageSize: commentPageSize}, - }) + for raw, err := range c.ListIssueCommentsIter(owner, repo, pr) { if err != nil { return nil, log.E("forge.GetCommentBodies", "failed to get PR comments", err) } - if len(raw) == 0 { - break + comment := Comment{ + ID: raw.ID, + Body: raw.Body, + CreatedAt: raw.Created, + UpdatedAt: raw.Updated, } - - for _, rc := range raw { - comment := Comment{ - ID: rc.ID, - Body: rc.Body, - CreatedAt: rc.Created, - UpdatedAt: rc.Updated, - } - if rc.Poster != nil { - comment.Author = rc.Poster.UserName - } - comments = append(comments, comment) + if raw.Poster != nil { + comment.Author = raw.Poster.UserName } - - if len(raw) < commentPageSize { - break - } - page++ + comments = append(comments, comment) } return comments, nil diff --git a/gitea/issues.go b/gitea/issues.go index a60344b..d8045a3 100644 --- a/gitea/issues.go +++ b/gitea/issues.go @@ -202,6 +202,32 @@ func (c *Client) ListPullRequestsIter(owner, repo string, state string) iter.Seq } } +// ListIssueCommentsIter returns an iterator over comments for an issue. +// Usage: ListIssueCommentsIter(...) +func (c *Client) ListIssueCommentsIter(owner, repo string, number int64) iter.Seq2[*gitea.Comment, error] { + return func(yield func(*gitea.Comment, error) bool) { + page := 1 + for { + comments, resp, err := c.api.ListIssueComments(owner, repo, number, gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if err != nil { + yield(nil, log.E("gitea.ListIssueComments", "failed to list comments", err)) + return + } + for _, comment := range comments { + if !yield(comment, nil) { + return + } + } + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + } +} + // GetPullRequest returns a single pull request by number. // Usage: GetPullRequest(...) func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRequest, error) { diff --git a/gitea/issues_test.go b/gitea/issues_test.go index 2725bf6..fe8eb80 100644 --- a/gitea/issues_test.go +++ b/gitea/issues_test.go @@ -5,6 +5,7 @@ package gitea import ( "net/http" "net/http/httptest" + "strconv" "testing" giteaSDK "code.gitea.io/sdk/gitea" @@ -41,6 +42,43 @@ func newPaginatedIssuesClient(t *testing.T) (*Client, *httptest.Server) { return client, srv } +func newPaginatedCommentsClient(t *testing.T) (*Client, *httptest.Server) { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "2": + jsonResponse(w, []map[string]any{ + {"id": 150, "body": "comment 51", "user": map[string]any{"login": "user51"}, "created_at": "2026-01-02T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"}, + }) + case "3": + jsonResponse(w, []map[string]any{}) + default: + w.Header().Set("Link", `; rel="next", ; rel="last"`) + comments := make([]map[string]any, 0, 50) + for i := 1; i <= 50; i++ { + comments = append(comments, map[string]any{ + "id": 99 + i, + "body": "comment " + strconv.Itoa(i), + "user": map[string]any{"login": "user" + strconv.Itoa(i)}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }) + } + jsonResponse(w, comments) + } + }) + + srv := httptest.NewServer(mux) + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + return client, srv +} + func TestClient_ListIssues_Good(t *testing.T) { client, srv := newTestClient(t) defer srv.Close() @@ -136,6 +174,22 @@ func TestClient_GetIssue_Bad_ServerError_Good(t *testing.T) { assert.Contains(t, err.Error(), "failed to get issue") } +func TestClient_ListIssueCommentsIter_Good_Paginates_Good(t *testing.T) { + client, srv := newPaginatedCommentsClient(t) + defer srv.Close() + + var bodies []string + for comment, err := range client.ListIssueCommentsIter("test-org", "org-repo", 1) { + require.NoError(t, err) + bodies = append(bodies, comment.Body) + } + + require.Len(t, bodies, 51) + assert.Equal(t, "comment 1", bodies[0]) + assert.Equal(t, "comment 50", bodies[49]) + assert.Equal(t, "comment 51", bodies[50]) +} + func TestClient_CreateIssue_Good(t *testing.T) { client, srv := newTestClient(t) defer srv.Close() diff --git a/gitea/meta.go b/gitea/meta.go index 30c4891..61c63cc 100644 --- a/gitea/meta.go +++ b/gitea/meta.go @@ -5,8 +5,6 @@ package gitea import ( "time" - "code.gitea.io/sdk/gitea" - "dappco.re/go/core/log" ) @@ -78,19 +76,11 @@ func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { // Fetch comment count from the issue side (PRs are issues in Gitea). // Paginate to get an accurate count. count := 0 - page := 1 - for { - comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, - }) - if listErr != nil { + for _, err := range c.ListIssueCommentsIter(owner, repo, pr) { + if err != nil { break } - count += len(comments) - if len(comments) < commentPageSize { - break - } - page++ + count++ } meta.CommentCount = count @@ -102,37 +92,21 @@ func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { // Usage: GetCommentBodies(...) func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) { var comments []Comment - page := 1 - - for { - raw, _, err := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, - }) + for raw, err := range c.ListIssueCommentsIter(owner, repo, pr) { if err != nil { return nil, log.E("gitea.GetCommentBodies", "failed to get PR comments", err) } - if len(raw) == 0 { - break + comment := Comment{ + ID: raw.ID, + Body: raw.Body, + CreatedAt: raw.Created, + UpdatedAt: raw.Updated, } - - for _, rc := range raw { - comment := Comment{ - ID: rc.ID, - Body: rc.Body, - CreatedAt: rc.Created, - UpdatedAt: rc.Updated, - } - if rc.Poster != nil { - comment.Author = rc.Poster.UserName - } - comments = append(comments, comment) + if raw.Poster != nil { + comment.Author = raw.Poster.UserName } - - if len(raw) < commentPageSize { - break - } - page++ + comments = append(comments, comment) } return comments, nil