feat(discussion/client): add GetCommentReplies with paginated reply fetching
Extract discussionReplyNode and mapReplyFromNode as reusable types for reply nodes. Add GetCommentReplies to the DiscussionClient interface, implemented using a combined node(id:) and repository.discussion query since the Discussion type does not expose a comment(id:) field. Add ExportReply() for leaf reply nodes (no nested replies) and include cursor/next pagination fields in the comment Export() replies object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
9baeaf2043
commit
b090b4d2fb
4 changed files with 308 additions and 50 deletions
|
|
@ -13,6 +13,7 @@ type DiscussionClient interface {
|
|||
Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error)
|
||||
GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error)
|
||||
GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error)
|
||||
GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error)
|
||||
ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error)
|
||||
Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error)
|
||||
Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error)
|
||||
|
|
|
|||
|
|
@ -392,6 +392,43 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc
|
|||
return &d, nil
|
||||
}
|
||||
|
||||
// discussionReplyNode is the GraphQL response shape for a reply to a discussion comment.
|
||||
type discussionReplyNode struct {
|
||||
ID string
|
||||
URL string `graphql:"url"`
|
||||
Author actorNode
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
IsAnswer bool
|
||||
UpvoteCount int
|
||||
ReactionGroups []struct {
|
||||
Content string
|
||||
Users struct {
|
||||
TotalCount int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapReplyFromNode converts a discussionReplyNode into the domain DiscussionComment type.
|
||||
func mapReplyFromNode(n discussionReplyNode) DiscussionComment {
|
||||
rc := DiscussionComment{
|
||||
ID: n.ID,
|
||||
URL: n.URL,
|
||||
Author: mapActorFromListNode(n.Author),
|
||||
Body: n.Body,
|
||||
CreatedAt: n.CreatedAt,
|
||||
IsAnswer: n.IsAnswer,
|
||||
UpvoteCount: n.UpvoteCount,
|
||||
}
|
||||
for _, rg := range n.ReactionGroups {
|
||||
rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{
|
||||
Content: rg.Content,
|
||||
TotalCount: rg.Users.TotalCount,
|
||||
})
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
// discussionCommentNode is the GraphQL response shape for a discussion comment
|
||||
// including nested replies.
|
||||
type discussionCommentNode struct {
|
||||
|
|
@ -410,21 +447,7 @@ type discussionCommentNode struct {
|
|||
}
|
||||
Replies struct {
|
||||
TotalCount int
|
||||
Nodes []struct {
|
||||
ID string
|
||||
URL string `graphql:"url"`
|
||||
Author actorNode
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
IsAnswer bool
|
||||
UpvoteCount int
|
||||
ReactionGroups []struct {
|
||||
Content string
|
||||
Users struct {
|
||||
TotalCount int
|
||||
}
|
||||
}
|
||||
}
|
||||
Nodes []discussionReplyNode
|
||||
} `graphql:"replies(last: 4)"`
|
||||
}
|
||||
|
||||
|
|
@ -449,22 +472,7 @@ func mapCommentFromNode(n discussionCommentNode) DiscussionComment {
|
|||
|
||||
replyComments := make([]DiscussionComment, len(n.Replies.Nodes))
|
||||
for i, r := range n.Replies.Nodes {
|
||||
rc := DiscussionComment{
|
||||
ID: r.ID,
|
||||
URL: r.URL,
|
||||
Author: mapActorFromListNode(r.Author),
|
||||
Body: r.Body,
|
||||
CreatedAt: r.CreatedAt,
|
||||
IsAnswer: r.IsAnswer,
|
||||
UpvoteCount: r.UpvoteCount,
|
||||
}
|
||||
for _, rg := range r.ReactionGroups {
|
||||
rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{
|
||||
Content: rg.Content,
|
||||
TotalCount: rg.Users.TotalCount,
|
||||
})
|
||||
}
|
||||
replyComments[i] = rc
|
||||
replyComments[i] = mapReplyFromNode(r)
|
||||
}
|
||||
dc.Replies = DiscussionCommentList{
|
||||
Comments: replyComments,
|
||||
|
|
@ -574,6 +582,156 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, li
|
|||
return &d, nil
|
||||
}
|
||||
|
||||
// GetCommentReplies fetches a discussion and a single comment with its
|
||||
// paginated replies. It uses the top-level node(id:) query for the comment
|
||||
// because the Discussion type does not expose a comment(id:) field.
|
||||
func (c *discussionClient) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
HasDiscussionsEnabled bool
|
||||
Discussion struct {
|
||||
discussionListNode
|
||||
} `graphql:"discussion(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
Node *struct {
|
||||
DiscussionComment struct {
|
||||
ID string
|
||||
URL string `graphql:"url"`
|
||||
Author actorNode
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
IsAnswer bool
|
||||
UpvoteCount int
|
||||
ReactionGroups []struct {
|
||||
Content string
|
||||
Users struct {
|
||||
TotalCount int
|
||||
}
|
||||
}
|
||||
Replies struct {
|
||||
TotalCount int
|
||||
PageInfo struct {
|
||||
EndCursor string
|
||||
HasNextPage bool
|
||||
StartCursor string
|
||||
HasPreviousPage bool
|
||||
}
|
||||
Nodes []discussionReplyNode
|
||||
} `graphql:"replies(first: $first, last: $last, after: $after, before: $before)"`
|
||||
} `graphql:"... on DiscussionComment"`
|
||||
} `graphql:"node(id: $commentID)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(number),
|
||||
"commentID": githubv4.ID(commentID),
|
||||
"first": (*githubv4.Int)(nil),
|
||||
"last": (*githubv4.Int)(nil),
|
||||
"after": (*githubv4.String)(nil),
|
||||
"before": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
if newest {
|
||||
variables["last"] = githubv4.Int(limit)
|
||||
if after != "" {
|
||||
variables["before"] = githubv4.String(after)
|
||||
}
|
||||
} else {
|
||||
variables["first"] = githubv4.Int(limit)
|
||||
if after != "" {
|
||||
variables["after"] = githubv4.String(after)
|
||||
}
|
||||
}
|
||||
|
||||
err := c.gql.Query(repo.RepoHost(), "DiscussionCommentReplies", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !query.Repository.HasDiscussionsEnabled {
|
||||
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
|
||||
}
|
||||
|
||||
// The query above should already error for an invalid node ID, but guard against nil.
|
||||
if query.Node == nil {
|
||||
return nil, fmt.Errorf("comment %s not found", commentID)
|
||||
}
|
||||
|
||||
src := query.Node.DiscussionComment
|
||||
if src.ID == "" {
|
||||
return nil, fmt.Errorf("node %s is not a discussion comment", commentID)
|
||||
}
|
||||
|
||||
d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode)
|
||||
|
||||
for _, rg := range query.Repository.Discussion.ReactionGroups {
|
||||
d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{
|
||||
Content: rg.Content,
|
||||
TotalCount: rg.Users.TotalCount,
|
||||
})
|
||||
}
|
||||
|
||||
dc := DiscussionComment{
|
||||
ID: src.ID,
|
||||
URL: src.URL,
|
||||
Author: mapActorFromListNode(src.Author),
|
||||
Body: src.Body,
|
||||
CreatedAt: src.CreatedAt,
|
||||
IsAnswer: src.IsAnswer,
|
||||
UpvoteCount: src.UpvoteCount,
|
||||
}
|
||||
|
||||
for _, rg := range src.ReactionGroups {
|
||||
dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{
|
||||
Content: rg.Content,
|
||||
TotalCount: rg.Users.TotalCount,
|
||||
})
|
||||
}
|
||||
|
||||
replies := make([]DiscussionComment, len(src.Replies.Nodes))
|
||||
for i, r := range src.Replies.Nodes {
|
||||
replies[i] = mapReplyFromNode(r)
|
||||
}
|
||||
|
||||
// When using "last" (newest order), the API returns items in chronological
|
||||
// order. Reverse them so the newest reply appears first.
|
||||
if newest {
|
||||
slices.Reverse(replies)
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if newest {
|
||||
if src.Replies.PageInfo.HasPreviousPage {
|
||||
nextCursor = src.Replies.PageInfo.StartCursor
|
||||
}
|
||||
} else {
|
||||
if src.Replies.PageInfo.HasNextPage {
|
||||
nextCursor = src.Replies.PageInfo.EndCursor
|
||||
}
|
||||
}
|
||||
|
||||
direction := DiscussionCommentListDirectionForward
|
||||
if newest {
|
||||
direction = DiscussionCommentListDirectionBackward
|
||||
}
|
||||
|
||||
dc.Replies = DiscussionCommentList{
|
||||
Comments: replies,
|
||||
TotalCount: src.Replies.TotalCount,
|
||||
Cursor: after,
|
||||
NextCursor: nextCursor,
|
||||
Direction: direction,
|
||||
}
|
||||
|
||||
d.Comments = DiscussionCommentList{
|
||||
Comments: []DiscussionComment{dc},
|
||||
TotalCount: 1,
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ var _ DiscussionClient = &DiscussionClientMock{}
|
|||
// GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) {
|
||||
// panic("mock out the GetByNumber method")
|
||||
// },
|
||||
// GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) {
|
||||
// panic("mock out the GetCommentReplies method")
|
||||
// },
|
||||
// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) {
|
||||
// panic("mock out the GetWithComments method")
|
||||
// },
|
||||
|
|
@ -79,6 +82,9 @@ type DiscussionClientMock struct {
|
|||
// GetByNumberFunc mocks the GetByNumber method.
|
||||
GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error)
|
||||
|
||||
// GetCommentRepliesFunc mocks the GetCommentReplies method.
|
||||
GetCommentRepliesFunc func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error)
|
||||
|
||||
// GetWithCommentsFunc mocks the GetWithComments method.
|
||||
GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error)
|
||||
|
||||
|
|
@ -145,6 +151,21 @@ type DiscussionClientMock struct {
|
|||
// Number is the number argument value.
|
||||
Number int
|
||||
}
|
||||
// GetCommentReplies holds details about calls to the GetCommentReplies method.
|
||||
GetCommentReplies []struct {
|
||||
// Repo is the repo argument value.
|
||||
Repo ghrepo.Interface
|
||||
// Number is the number argument value.
|
||||
Number int
|
||||
// CommentID is the commentID argument value.
|
||||
CommentID string
|
||||
// Limit is the limit argument value.
|
||||
Limit int
|
||||
// After is the after argument value.
|
||||
After string
|
||||
// Newest is the newest argument value.
|
||||
Newest bool
|
||||
}
|
||||
// GetWithComments holds details about calls to the GetWithComments method.
|
||||
GetWithComments []struct {
|
||||
// Repo is the repo argument value.
|
||||
|
|
@ -230,20 +251,21 @@ type DiscussionClientMock struct {
|
|||
Input UpdateDiscussionInput
|
||||
}
|
||||
}
|
||||
lockAddComment sync.RWMutex
|
||||
lockClose sync.RWMutex
|
||||
lockCreate sync.RWMutex
|
||||
lockGetByNumber sync.RWMutex
|
||||
lockGetWithComments sync.RWMutex
|
||||
lockList sync.RWMutex
|
||||
lockListCategories sync.RWMutex
|
||||
lockLock sync.RWMutex
|
||||
lockMarkAnswer sync.RWMutex
|
||||
lockReopen sync.RWMutex
|
||||
lockSearch sync.RWMutex
|
||||
lockUnlock sync.RWMutex
|
||||
lockUnmarkAnswer sync.RWMutex
|
||||
lockUpdate sync.RWMutex
|
||||
lockAddComment sync.RWMutex
|
||||
lockClose sync.RWMutex
|
||||
lockCreate sync.RWMutex
|
||||
lockGetByNumber sync.RWMutex
|
||||
lockGetCommentReplies sync.RWMutex
|
||||
lockGetWithComments sync.RWMutex
|
||||
lockList sync.RWMutex
|
||||
lockListCategories sync.RWMutex
|
||||
lockLock sync.RWMutex
|
||||
lockMarkAnswer sync.RWMutex
|
||||
lockReopen sync.RWMutex
|
||||
lockSearch sync.RWMutex
|
||||
lockUnlock sync.RWMutex
|
||||
lockUnmarkAnswer sync.RWMutex
|
||||
lockUpdate sync.RWMutex
|
||||
}
|
||||
|
||||
// AddComment calls AddCommentFunc.
|
||||
|
|
@ -402,6 +424,58 @@ func (mock *DiscussionClientMock) GetByNumberCalls() []struct {
|
|||
return calls
|
||||
}
|
||||
|
||||
// GetCommentReplies calls GetCommentRepliesFunc.
|
||||
func (mock *DiscussionClientMock) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) {
|
||||
if mock.GetCommentRepliesFunc == nil {
|
||||
panic("DiscussionClientMock.GetCommentRepliesFunc: method is nil but DiscussionClient.GetCommentReplies was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Repo ghrepo.Interface
|
||||
Number int
|
||||
CommentID string
|
||||
Limit int
|
||||
After string
|
||||
Newest bool
|
||||
}{
|
||||
Repo: repo,
|
||||
Number: number,
|
||||
CommentID: commentID,
|
||||
Limit: limit,
|
||||
After: after,
|
||||
Newest: newest,
|
||||
}
|
||||
mock.lockGetCommentReplies.Lock()
|
||||
mock.calls.GetCommentReplies = append(mock.calls.GetCommentReplies, callInfo)
|
||||
mock.lockGetCommentReplies.Unlock()
|
||||
return mock.GetCommentRepliesFunc(repo, number, commentID, limit, after, newest)
|
||||
}
|
||||
|
||||
// GetCommentRepliesCalls gets all the calls that were made to GetCommentReplies.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedDiscussionClient.GetCommentRepliesCalls())
|
||||
func (mock *DiscussionClientMock) GetCommentRepliesCalls() []struct {
|
||||
Repo ghrepo.Interface
|
||||
Number int
|
||||
CommentID string
|
||||
Limit int
|
||||
After string
|
||||
Newest bool
|
||||
} {
|
||||
var calls []struct {
|
||||
Repo ghrepo.Interface
|
||||
Number int
|
||||
CommentID string
|
||||
Limit int
|
||||
After string
|
||||
Newest bool
|
||||
}
|
||||
mock.lockGetCommentReplies.RLock()
|
||||
calls = mock.calls.GetCommentReplies
|
||||
mock.lockGetCommentReplies.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetWithComments calls GetWithCommentsFunc.
|
||||
func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) {
|
||||
if mock.GetWithCommentsFunc == nil {
|
||||
|
|
|
|||
|
|
@ -185,8 +185,37 @@ type DiscussionComment struct {
|
|||
func (c DiscussionComment) Export() map[string]interface{} {
|
||||
replies := make([]interface{}, len(c.Replies.Comments))
|
||||
for i, r := range c.Replies.Comments {
|
||||
replies[i] = r.Export()
|
||||
replies[i] = r.ExportReply()
|
||||
}
|
||||
reactions := make([]interface{}, len(c.ReactionGroups))
|
||||
for i, rg := range c.ReactionGroups {
|
||||
reactions[i] = rg.Export()
|
||||
}
|
||||
repliesMap := map[string]interface{}{
|
||||
"totalCount": c.Replies.TotalCount,
|
||||
"nodes": replies,
|
||||
}
|
||||
if c.Replies.Cursor != "" {
|
||||
repliesMap["cursor"] = c.Replies.Cursor
|
||||
}
|
||||
if c.Replies.NextCursor != "" {
|
||||
repliesMap["next"] = c.Replies.NextCursor
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"url": c.URL,
|
||||
"author": c.Author.Export(),
|
||||
"body": c.Body,
|
||||
"createdAt": c.CreatedAt,
|
||||
"isAnswer": c.IsAnswer,
|
||||
"upvoteCount": c.UpvoteCount,
|
||||
"reactionGroups": reactions,
|
||||
"replies": repliesMap,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportReply returns a reply as a map for JSON output, without nested replies.
|
||||
func (c DiscussionComment) ExportReply() map[string]interface{} {
|
||||
reactions := make([]interface{}, len(c.ReactionGroups))
|
||||
for i, rg := range c.ReactionGroups {
|
||||
reactions[i] = rg.Export()
|
||||
|
|
@ -200,10 +229,6 @@ func (c DiscussionComment) Export() map[string]interface{} {
|
|||
"isAnswer": c.IsAnswer,
|
||||
"upvoteCount": c.UpvoteCount,
|
||||
"reactionGroups": reactions,
|
||||
"replies": map[string]interface{}{
|
||||
"totalCount": c.Replies.TotalCount,
|
||||
"nodes": replies,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue