package client import ( "fmt" "net/http" "slices" "strings" "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/shurcooL/githubv4" ) // maxPageSize is the maximum number of items per page allowed by the GitHub GraphQL API. const maxPageSize = 100 type discussionClient struct { gql *api.Client } // NewDiscussionClient creates a DiscussionClient backed by the given HTTP client. func NewDiscussionClient(httpClient *http.Client) DiscussionClient { return &discussionClient{ gql: api.NewClientFromHTTP(httpClient), } } // actorNode is the GraphQL response shape for an Actor union (User or Bot) // used in discussionListNode fields like Author and AnswerChosenBy. type actorNode struct { TypeName string `graphql:"__typename"` Login string User struct { ID string Name string } `graphql:"... on User"` Bot struct { ID string } `graphql:"... on Bot"` } // mapActorFromListNode converts an actorNode into the domain DiscussionActor type. func mapActorFromListNode(n actorNode) DiscussionActor { a := DiscussionActor{Login: n.Login} switch n.TypeName { case "User": a.ID = n.User.ID a.Name = n.User.Name case "Bot": a.ID = n.Bot.ID } return a } // discussionListNode is the GraphQL response shape for a discussion in // list and search results. It covers high-level fields only (no comments, or // other detail-level data that commands like view would need). type discussionListNode struct { ID string Number int Title string Body string URL string `graphql:"url"` Closed bool StateReason string Author actorNode Category struct { ID string Name string Slug string Emoji string IsAnswerable bool } Labels struct { Nodes []struct { ID string Name string Color string } } `graphql:"labels(first: 20)"` IsAnswered bool AnswerChosenAt time.Time AnswerChosenBy *actorNode ReactionGroups []struct { Content string Users struct { TotalCount int } } `graphql:"reactionGroups"` CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time Locked bool } // mapDiscussionFromListNode converts a discussionListNode into the domain Discussion type. func mapDiscussionFromListNode(n discussionListNode) Discussion { d := Discussion{ ID: n.ID, Number: n.Number, Title: n.Title, Body: n.Body, URL: n.URL, Closed: n.Closed, StateReason: n.StateReason, Author: mapActorFromListNode(n.Author), Category: DiscussionCategory{ ID: n.Category.ID, Name: n.Category.Name, Slug: n.Category.Slug, Emoji: n.Category.Emoji, IsAnswerable: n.Category.IsAnswerable, }, Answered: n.IsAnswered, AnswerChosenAt: n.AnswerChosenAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, ClosedAt: n.ClosedAt, Locked: n.Locked, } if n.AnswerChosenBy != nil { a := mapActorFromListNode(*n.AnswerChosenBy) d.AnswerChosenBy = &a } d.Labels = make([]DiscussionLabel, len(n.Labels.Nodes)) for i, l := range n.Labels.Nodes { d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} } return d } func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) { if limit <= 0 { return nil, fmt.Errorf("limit argument must be positive: %v", limit) } var query struct { Repository struct { HasDiscussionsEnabled bool Discussions struct { TotalCount int PageInfo struct { HasNextPage bool EndCursor string } Nodes []discussionListNode } `graphql:"discussions(first: $first, after: $after, orderBy: $orderBy, categoryId: $categoryId, states: $states, answered: $answered)"` } `graphql:"repository(owner: $owner, name: $name)"` } orderField := githubv4.DiscussionOrderFieldUpdatedAt orderDir := githubv4.OrderDirectionDesc if filters.OrderBy != "" { switch filters.OrderBy { case OrderByCreated: orderField = githubv4.DiscussionOrderFieldCreatedAt case OrderByUpdated: orderField = githubv4.DiscussionOrderFieldUpdatedAt default: return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) } } if filters.Direction != "" { switch filters.Direction { case OrderDirectionAsc: orderDir = githubv4.OrderDirectionAsc case OrderDirectionDesc: orderDir = githubv4.OrderDirectionDesc default: return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "after": (*githubv4.String)(nil), "orderBy": githubv4.DiscussionOrder{Field: orderField, Direction: orderDir}, "categoryId": (*githubv4.ID)(nil), "states": (*[]githubv4.DiscussionState)(nil), "answered": (*githubv4.Boolean)(nil), } if after != "" { variables["after"] = githubv4.String(after) } if filters.CategoryID != "" { variables["categoryId"] = githubv4.ID(filters.CategoryID) } if filters.State != nil { switch *filters.State { case FilterStateOpen: variables["states"] = &[]githubv4.DiscussionState{githubv4.DiscussionStateOpen} case FilterStateClosed: variables["states"] = &[]githubv4.DiscussionState{githubv4.DiscussionStateClosed} default: return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) } } if filters.Answered != nil { variables["answered"] = githubv4.Boolean(*filters.Answered) } var result DiscussionListResult remaining := limit for { variables["first"] = githubv4.Int(min(remaining, maxPageSize)) if err := c.gql.Query(repo.RepoHost(), "DiscussionList", &query, variables); err != nil { return nil, err } if !query.Repository.HasDiscussionsEnabled { // This would be the same over every iteration, so if we're going to return we will at the first page. return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } result.TotalCount = query.Repository.Discussions.TotalCount for _, n := range query.Repository.Discussions.Nodes { result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n)) } remaining -= len(query.Repository.Discussions.Nodes) if remaining <= 0 || !query.Repository.Discussions.PageInfo.HasNextPage { if query.Repository.Discussions.PageInfo.HasNextPage { result.NextCursor = query.Repository.Discussions.PageInfo.EndCursor } break } variables["after"] = githubv4.String(query.Repository.Discussions.PageInfo.EndCursor) } return &result, nil } func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) { if limit <= 0 { return nil, fmt.Errorf("limit argument must be positive: %v", limit) } var query struct { Search struct { DiscussionCount int PageInfo struct { HasNextPage bool EndCursor string } Nodes []struct { Discussion discussionListNode `graphql:"... on Discussion"` } } `graphql:"search(query: $query, type: DISCUSSION, first: $first, after: $after)"` } qualifiers := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())} if filters.State != nil { switch *filters.State { case FilterStateOpen: qualifiers = append(qualifiers, "is:open") case FilterStateClosed: qualifiers = append(qualifiers, "is:closed") default: return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) } } if filters.Author != "" { qualifiers = append(qualifiers, fmt.Sprintf("author:%q", filters.Author)) } for _, l := range filters.Labels { qualifiers = append(qualifiers, fmt.Sprintf("label:%q", l)) } if filters.Category != "" { qualifiers = append(qualifiers, fmt.Sprintf("category:%q", filters.Category)) } if filters.Answered != nil { if *filters.Answered { qualifiers = append(qualifiers, "is:answered") } else { qualifiers = append(qualifiers, "is:unanswered") } } orderField := "updated" orderDir := "desc" if filters.OrderBy != "" { switch filters.OrderBy { case OrderByCreated: orderField = "created" case OrderByUpdated: orderField = "updated" default: return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) } } if filters.Direction != "" { switch filters.Direction { case OrderDirectionAsc: orderDir = "asc" case OrderDirectionDesc: orderDir = "desc" default: return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } } qualifiers = append(qualifiers, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) searchQuery := strings.Join(qualifiers, " ") if filters.Keywords != "" { searchQuery += " " + filters.Keywords } variables := map[string]interface{}{ "query": githubv4.String(searchQuery), "after": (*githubv4.String)(nil), } if after != "" { variables["after"] = githubv4.String(after) } var result DiscussionListResult remaining := limit for { variables["first"] = githubv4.Int(min(remaining, maxPageSize)) if err := c.gql.Query(repo.RepoHost(), "DiscussionListSearch", &query, variables); err != nil { return nil, err } result.TotalCount = query.Search.DiscussionCount for _, n := range query.Search.Nodes { result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n.Discussion)) } remaining -= len(query.Search.Nodes) if remaining <= 0 || !query.Search.PageInfo.HasNextPage { if query.Search.PageInfo.HasNextPage { result.NextCursor = query.Search.PageInfo.EndCursor } break } variables["after"] = githubv4.String(query.Search.PageInfo.EndCursor) } return &result, nil } func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) { var query struct { Repository struct { HasDiscussionsEnabled bool Discussion struct { discussionListNode Comments struct { TotalCount int } } `graphql:"discussion(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "number": githubv4.Int(number), } err := c.gql.Query(repo.RepoHost(), "DiscussionMinimal", &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()) } d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) d.Comments = DiscussionCommentList{TotalCount: query.Repository.Discussion.Comments.TotalCount} for _, rg := range query.Repository.Discussion.ReactionGroups { d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ Content: rg.Content, TotalCount: rg.Users.TotalCount, }) } 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 { 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 Nodes []discussionReplyNode } `graphql:"replies(last: 4)"` } // mapCommentFromNode converts a discussionCommentNode into the domain DiscussionComment type. func mapCommentFromNode(n discussionCommentNode) DiscussionComment { dc := 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 { dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{ Content: rg.Content, TotalCount: rg.Users.TotalCount, }) } replyComments := make([]DiscussionComment, len(n.Replies.Nodes)) for i, r := range n.Replies.Nodes { replyComments[i] = mapReplyFromNode(r) } dc.Replies = DiscussionCommentList{ Comments: replyComments, TotalCount: n.Replies.TotalCount, Direction: DiscussionCommentListDirectionBackward, } return dc } func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, limit int, after string, newest bool) (*Discussion, error) { var query struct { Repository struct { HasDiscussionsEnabled bool Discussion struct { discussionListNode Comments struct { TotalCount int PageInfo struct { EndCursor string HasNextPage bool StartCursor string HasPreviousPage bool } Nodes []discussionCommentNode } `graphql:"comments(first: $first, last: $last, after: $after, before: $before)"` } `graphql:"discussion(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "number": githubv4.Int(number), "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(), "DiscussionWithComments", &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()) } src := query.Repository.Discussion d := mapDiscussionFromListNode(src.discussionListNode) for _, rg := range src.ReactionGroups { d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ Content: rg.Content, TotalCount: rg.Users.TotalCount, }) } comments := make([]DiscussionComment, len(src.Comments.Nodes)) for i, c := range src.Comments.Nodes { comments[i] = mapCommentFromNode(c) } // When using "last" (newest order), the API returns items in chronological // order. Reverse them so the newest comment appears first. if newest { slices.Reverse(comments) } nextCursor := "" if newest { if src.Comments.PageInfo.HasPreviousPage { nextCursor = src.Comments.PageInfo.StartCursor } } else { if src.Comments.PageInfo.HasNextPage { nextCursor = src.Comments.PageInfo.EndCursor } } direction := DiscussionCommentListDirectionForward if newest { direction = DiscussionCommentListDirectionBackward } d.Comments = DiscussionCommentList{ Comments: comments, TotalCount: src.Comments.TotalCount, Cursor: after, NextCursor: nextCursor, Direction: direction, } 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 { HasDiscussionsEnabled bool DiscussionCategories struct { Nodes []struct { ID string Name string Slug string Emoji string IsAnswerable bool } } `graphql:"discussionCategories(first: 100)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), } if err := c.gql.Query(repo.RepoHost(), "DiscussionCategoryList", &query, variables); err != nil { return nil, err } if !query.Repository.HasDiscussionsEnabled { return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } categories := make([]DiscussionCategory, len(query.Repository.DiscussionCategories.Nodes)) for i, n := range query.Repository.DiscussionCategories.Nodes { categories[i] = DiscussionCategory{ ID: n.ID, Name: n.Name, Slug: n.Slug, Emoji: n.Emoji, IsAnswerable: n.IsAnswerable, } } return categories, nil } func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) { return nil, fmt.Errorf("not implemented") } func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) { return nil, fmt.Errorf("not implemented") } func (c *discussionClient) Close(_ ghrepo.Interface, _ string, _ CloseReason) (*Discussion, error) { return nil, fmt.Errorf("not implemented") } func (c *discussionClient) Reopen(_ ghrepo.Interface, _ string) (*Discussion, error) { return nil, fmt.Errorf("not implemented") } func (c *discussionClient) AddComment(_ ghrepo.Interface, _ string, _ string, _ string) (*DiscussionComment, error) { return nil, fmt.Errorf("not implemented") } func (c *discussionClient) Lock(_ ghrepo.Interface, _ string, _ string) error { return fmt.Errorf("not implemented") } func (c *discussionClient) Unlock(_ ghrepo.Interface, _ string) error { return fmt.Errorf("not implemented") } func (c *discussionClient) MarkAnswer(_ ghrepo.Interface, _ string) error { return fmt.Errorf("not implemented") } func (c *discussionClient) UnmarkAnswer(_ ghrepo.Interface, _ string) error { return fmt.Errorf("not implemented") }