feat(scm): add issue comment iterators
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Successful in 2m17s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 08:58:12 +00:00
parent d852087c45
commit 5bb8e61708
7 changed files with 185 additions and 77 deletions

View file

@ -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` |

View file

@ -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 {

View file

@ -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", `</api/v1/repos/test-org/org-repo/issues/1/comments?page=2>; rel="next", </api/v1/repos/test-org/org-repo/issues/1/comments?page=2>; 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()

View file

@ -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

View file

@ -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) {

View file

@ -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", `</api/v1/repos/test-org/org-repo/issues/1/comments?page=2>; rel="next", </api/v1/repos/test-org/org-repo/issues/1/comments?page=2>; 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()

View file

@ -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