diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go new file mode 100644 index 00000000000..f56906f7053 --- /dev/null +++ b/pkg/cmd/discussion/client/client.go @@ -0,0 +1,28 @@ +// Package client provides an abstraction layer for interacting with the +// GitHub Discussions GraphQL API. The DiscussionClient interface defines all +// supported operations and can be replaced with a mock in tests. +package client + +import "github.com/cli/cli/v2/internal/ghrepo" + +//go:generate moq -rm -out client_mock.go . DiscussionClient + +// DiscussionClient defines operations for interacting with the GitHub Discussions API. +type DiscussionClient interface { + List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) + 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) + ListLabels(repo ghrepo.Interface) ([]DiscussionLabel, error) + Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) + Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) + Close(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) + Reopen(repo ghrepo.Interface, id string) (*Discussion, error) + AddComment(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) + Lock(repo ghrepo.Interface, id string, reason string) error + Unlock(repo ghrepo.Interface, id string) error + MarkAnswer(repo ghrepo.Interface, commentID string) error + UnmarkAnswer(repo ghrepo.Interface, commentID string) error +} diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go new file mode 100644 index 00000000000..318caefb34b --- /dev/null +++ b/pkg/cmd/discussion/client/client_impl.go @@ -0,0 +1,1058 @@ +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 +} + +// repositoryMeta holds the node ID and feature flags fetched for a repository. +type repositoryMeta struct { + ID string + HasDiscussionsEnabled bool +} + +// getRepositoryMeta fetches the node ID and discussion-enabled flag for a repository. +func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repositoryMeta, error) { + var query struct { + Repository struct { + ID string + HasDiscussionsEnabled bool + } `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(), "RepositoryMeta", &query, variables); err != nil { + return nil, err + } + + return &repositoryMeta{ + ID: query.Repository.ID, + HasDiscussionsEnabled: query.Repository.HasDiscussionsEnabled, + }, nil +} + +// ListLabels fetches all labels for a repository, ordered alphabetically by name. +func (c *discussionClient) ListLabels(repo ghrepo.Interface) ([]DiscussionLabel, error) { + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID string + Name string + Color string + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"labels(first: 100, after: $endCursor, orderBy: {field: NAME, direction: ASC})"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + var labels []DiscussionLabel + for { + if err := c.gql.Query(repo.RepoHost(), "RepositoryLabelsForDiscussions", &query, variables); err != nil { + return nil, err + } + for _, n := range query.Repository.Labels.Nodes { + labels = append(labels, DiscussionLabel{ID: n.ID, Name: n.Name, Color: n.Color}) + } + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) + } + + return labels, nil +} + +// editDiscussionLabels adds and removes labels on a discussion. Removals are +// applied before additions. Either slice may be nil or empty to skip that step. +// Returns the discussion state as returned by the last mutation executed. +func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussionID string, addIDs, removeIDs []string) (*discussionListNode, error) { + var node *discussionListNode + + if len(removeIDs) > 0 { + ids := make([]githubv4.ID, len(removeIDs)) + for i, id := range removeIDs { + ids[i] = githubv4.ID(id) + } + + var mutation struct { + RemoveLabelsFromLabelable struct { + Labelable struct { + Discussion struct { + discussionListNode + } `graphql:"... on Discussion"` + } + } `graphql:"removeLabelsFromLabelable(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.RemoveLabelsFromLabelableInput{ + LabelableID: githubv4.ID(discussionID), + LabelIDs: ids, + }, + } + + if err := c.gql.Mutate(repo.RepoHost(), "RemoveLabelsFromDiscussion", &mutation, variables); err != nil { + return nil, err + } + node = &mutation.RemoveLabelsFromLabelable.Labelable.Discussion.discussionListNode + } + + if len(addIDs) > 0 { + ids := make([]githubv4.ID, len(addIDs)) + for i, id := range addIDs { + ids[i] = githubv4.ID(id) + } + + var mutation struct { + AddLabelsToLabelable struct { + Labelable struct { + Discussion struct { + discussionListNode + } `graphql:"... on Discussion"` + } + } `graphql:"addLabelsToLabelable(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.AddLabelsToLabelableInput{ + LabelableID: githubv4.ID(discussionID), + LabelIDs: ids, + }, + } + + if err := c.gql.Mutate(repo.RepoHost(), "AddLabelsToDiscussion", &mutation, variables); err != nil { + return nil, err + } + node = &mutation.AddLabelsToLabelable.Labelable.Discussion.discussionListNode + } + + return node, nil +} + +func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { + meta, err := c.getRepositoryMeta(repo) + if err != nil { + return nil, err + } + if !meta.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + var mutation struct { + CreateDiscussion struct { + Discussion struct { + discussionListNode + } + } `graphql:"createDiscussion(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.CreateDiscussionInput{ + RepositoryID: githubv4.ID(meta.ID), + CategoryID: githubv4.ID(input.CategoryID), + Title: githubv4.String(input.Title), + Body: githubv4.String(input.Body), + }, + } + + if err := c.gql.Mutate(repo.RepoHost(), "CreateDiscussion", &mutation, variables); err != nil { + return nil, err + } + + node := &mutation.CreateDiscussion.Discussion.discussionListNode + + if len(input.LabelIDs) > 0 { + labelNode, err := c.editDiscussionLabels(repo, node.ID, input.LabelIDs, nil) + if err != nil { + return nil, err + } + node = labelNode + } + + d := mapDiscussionFromListNode(*node) + + for _, rg := range node.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + return &d, nil +} + +func (c *discussionClient) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { + hasFieldUpdate := input.Title != nil || input.Body != nil || input.CategoryID != nil + hasLabelUpdate := len(input.AddLabelIDs) > 0 || len(input.RemoveLabelIDs) > 0 + + if !hasFieldUpdate && !hasLabelUpdate { + return nil, fmt.Errorf("nothing to update") + } + + var node *discussionListNode + + if hasFieldUpdate { + gqlInput := githubv4.UpdateDiscussionInput{ + DiscussionID: githubv4.ID(input.DiscussionID), + } + if input.Title != nil { + gqlInput.Title = githubv4.NewString(githubv4.String(*input.Title)) + } + if input.Body != nil { + gqlInput.Body = githubv4.NewString(githubv4.String(*input.Body)) + } + if input.CategoryID != nil { + id := githubv4.ID(*input.CategoryID) + gqlInput.CategoryID = &id + } + + var mutation struct { + UpdateDiscussion struct { + Discussion struct { + discussionListNode + } + } `graphql:"updateDiscussion(input: $input)"` + } + + variables := map[string]interface{}{ + "input": gqlInput, + } + + if err := c.gql.Mutate(repo.RepoHost(), "UpdateDiscussion", &mutation, variables); err != nil { + return nil, err + } + + node = &mutation.UpdateDiscussion.Discussion.discussionListNode + } + + if hasLabelUpdate { + labelNode, err := c.editDiscussionLabels(repo, input.DiscussionID, input.AddLabelIDs, input.RemoveLabelIDs) + if err != nil { + return nil, err + } + node = labelNode + } + + d := mapDiscussionFromListNode(*node) + + for _, rg := range node.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + return &d, nil +} + +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") +} diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go new file mode 100644 index 00000000000..089aa033f27 --- /dev/null +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -0,0 +1,3475 @@ +package client + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestDiscussionClient(reg *httpmock.Registry) DiscussionClient { + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + return NewDiscussionClient(httpClient) +} + +// minimalNode returns a minimal JSON discussion node with the given id and title. +func minimalNode(id, title string) string { + return heredoc.Docf(` + { + "id": %q, + "number": 1, + "title": %q, + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": { + "__typename": "User", + "login": "alice" + }, + "category": { + "id": "C1", + "name": "General", + "slug": "general", + "emoji": "", + "isAnswerable": false + }, + "answerChosenBy": null, + "labels": { + "nodes": [] + }, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + `, id, title) +} + +// minimalNodes returns count comma-separated minimal JSON discussion nodes. +func minimalNodes(count int) string { + nodes := make([]string, count) + for i := range nodes { + nodes[i] = minimalNode(fmt.Sprintf("D%d", i+1), fmt.Sprintf("Discussion %d", i+1)) + } + return strings.Join(nodes, ",") +} + +// listResp builds a mock repository.discussions JSON response. +func listResp(hasNext bool, cursor string, total int, nodes string) string { + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussions": { + "totalCount": %d, + "pageInfo": { + "hasNextPage": %t, + "endCursor": %q + }, + "nodes": [%s] + } + } + } + } + `, total, hasNext, cursor, nodes) +} + +// searchResp builds a mock search JSON response. +func searchResp(hasNext bool, cursor string, count int, nodes string) string { + return heredoc.Docf(` + { + "data": { + "search": { + "discussionCount": %d, + "pageInfo": { + "hasNextPage": %t, + "endCursor": %q + }, + "nodes": [%s] + } + } + } + `, count, hasNext, cursor, nodes) +} + +func TestList(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + richNode := heredoc.Doc(` + { + "id": "D_rich1", + "number": 42, + "title": "Rich discussion", + "body": "body text here", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": true, + "stateReason": "RESOLVED", + "isAnswered": true, + "answerChosenAt": "2024-06-01T12:00:00Z", + "author": { + "__typename": "User", + "login": "alice", + "id": "U1", + "name": "Alice" + }, + "category": { + "id": "C1", + "name": "Q&A", + "slug": "q-a", + "emoji": ":question:", + "isAnswerable": true + }, + "answerChosenBy": { + "__typename": "User", + "login": "bob", + "id": "U2", + "name": "Bob" + }, + "labels": { + "nodes": [ + {"id": "L1", "name": "bug", "color": "d73a4a"}, + {"id": "L2", "name": "enhancement", "color": "a2eeef"} + ] + }, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-02T00:00:00Z", + "closedAt": "2024-06-01T00:00:00Z", + "locked": true + } + `) + + emptyResp := listResp(false, "", 0, "") + disabledResp := heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": false, + "discussions": { + "totalCount": 0, + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + } + } + `) + + tests := []struct { + name string + filters ListFilters + after string + limit int + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantSingleDisc *Discussion + }{ + { + name: "maps all fields", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(false, "", 1, richNode)), + ) + }, + wantTotal: 1, + wantLen: 1, + wantSingleDisc: &Discussion{ + ID: "D_rich1", + Number: 42, + Title: "Rich discussion", + Body: "body text here", + URL: "https://github.com/OWNER/REPO/discussions/42", + Closed: true, + StateReason: "RESOLVED", + Author: DiscussionActor{ + ID: "U1", + Login: "alice", + Name: "Alice", + }, + Category: DiscussionCategory{ + ID: "C1", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{ + {ID: "L1", Name: "bug", Color: "d73a4a"}, + {ID: "L2", Name: "enhancement", Color: "a2eeef"}, + }, + Answered: true, + AnswerChosenAt: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC), + AnswerChosenBy: &DiscussionActor{ + ID: "U2", + Login: "bob", + Name: "Bob", + }, + Comments: DiscussionCommentList{}, + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC), + ClosedAt: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + Locked: true, + }, + }, + { + name: "empty list", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(emptyResp), + ) + }, + wantTotal: 0, + wantLen: 0, + }, + { + name: "discussions disabled", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(disabledResp), + ) + }, + wantErr: "discussions disabled", + }, + { + name: "limit zero", + limit: 0, + wantErr: "limit argument must be positive", + }, + { + name: "invalid orderBy", + limit: 10, + filters: ListFilters{OrderBy: "invalid"}, + wantErr: "unknown order-by field", + }, + { + name: "invalid direction", + limit: 10, + filters: ListFilters{Direction: "sideways"}, + wantErr: "unknown order direction", + }, + { + name: "invalid state", + limit: 10, + filters: ListFilters{State: new("merged")}, + wantErr: "unknown state filter", + }, + { + name: "with after cursor", + limit: 10, + after: "someCursor", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, "someCursor", vars["after"]) + }), + ) + }, + }, + { + name: "open state filter", + limit: 10, + filters: ListFilters{State: new(FilterStateOpen)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"OPEN"}, vars["states"]) + }), + ) + }, + }, + { + name: "closed state filter", + limit: 10, + filters: ListFilters{State: new(FilterStateClosed)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"CLOSED"}, vars["states"]) + }), + ) + }, + }, + { + name: "answered filter", + limit: 10, + filters: ListFilters{Answered: new(true)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, true, vars["answered"]) + }), + ) + }, + }, + { + name: "unanswered filter", + limit: 10, + filters: ListFilters{Answered: new(false)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, false, vars["answered"]) + }), + ) + }, + }, + { + name: "category ID filter", + limit: 10, + filters: ListFilters{CategoryID: "CAT123"}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, "CAT123", vars["categoryId"]) + }), + ) + }, + }, + { + name: "order by created asc", + limit: 10, + filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + orderBy, ok := vars["orderBy"].(map[string]interface{}) + require.True(t, ok, "orderBy should be a map") + assert.Equal(t, "CREATED_AT", orderBy["field"]) + assert.Equal(t, "ASC", orderBy["direction"]) + }), + ) + }, + }, + { + name: "order by updated desc", + limit: 10, + filters: ListFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + orderBy, ok := vars["orderBy"].(map[string]interface{}) + require.True(t, ok, "orderBy should be a map") + assert.Equal(t, "UPDATED_AT", orderBy["field"]) + assert.Equal(t, "DESC", orderBy["direction"]) + }), + ) + }, + }, + { + // Bot actors have no name; ID comes from the Bot.ID field. + name: "bot actor", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(false, "", 1, heredoc.Doc(` + { + "id": "D_bot", + "number": 1, + "title": "Bot post", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": { + "__typename": "Bot", + "login": "gh-bot", + "id": "bot-node-id" + }, + "category": { + "id": "C1", + "name": "General", + "slug": "general", + "emoji": "", + "isAnswerable": false + }, + "answerChosenBy": null, + "labels": { + "nodes": [] + }, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + `))), + ) + }, + wantLen: 1, + wantTotal: 1, + wantSingleDisc: &Discussion{ + ID: "D_bot", + Number: 1, + Title: "Bot post", + Author: DiscussionActor{ID: "bot-node-id", Login: "gh-bot", Name: ""}, + Category: DiscussionCategory{ID: "C1", Name: "General", Slug: "general"}, + Labels: []DiscussionLabel{}, + Comments: DiscussionCommentList{}, + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + { + // When limit > 100, the first page requests 100 and the second page + // requests the remainder, exercising the per-iteration first variable. + name: "limit greater than 100", + limit: 101, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(listResp(true, "pg2cursor", 101, minimalNodes(100)), func(_ string, vars map[string]interface{}) { + assert.Equal(t, float64(100), vars["first"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.GraphQLQuery(listResp(false, "", 101, minimalNode("D101", "Discussion 101")), func(_ string, vars map[string]interface{}) { + assert.Equal(t, float64(1), vars["first"]) + }), + ) + }, + wantLen: 101, + wantTotal: 101, + }, + { + // When the page has more items than requested, NextCursor is set. + name: "pagination sets next cursor", + limit: 1, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(true, "cursor42", 5, minimalNode("D1", "Discussion 1"))), + ) + }, + wantLen: 1, + wantTotal: 5, + wantCursor: "cursor42", + }, + { + // Two pages are fetched when limit exceeds the first page's results. + name: "pagination fetches multiple pages", + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(true, "cursor1", 2, minimalNode("D1", "First"))), + ) + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(false, "", 2, minimalNode("D2", "Second"))), + ) + }, + wantLen: 2, + wantTotal: 2, + wantTitles: []string{"First", "Second"}, + }, + { + name: "exact fit does not overfetch", + limit: 1, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(listResp(false, "", 1, minimalNode("D1", "Only one"))), + ) + }, + wantLen: 1, + wantTotal: 1, + wantTitles: []string{"Only one"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + result, err := c.List(repo, tt.filters, tt.after, tt.limit) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tt.wantTotal, result.TotalCount) + assert.Len(t, result.Discussions, tt.wantLen) + assert.Equal(t, tt.wantCursor, result.NextCursor) + + for i, title := range tt.wantTitles { + assert.Equal(t, title, result.Discussions[i].Title) + } + + if tt.wantSingleDisc != nil { + require.NotEmpty(t, result.Discussions) + assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) + } + }) + } +} + +func TestSearch(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + richNode := heredoc.Doc(` + { + "id": "D_rich1", + "number": 42, + "title": "Rich search result", + "body": "body text here", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": true, + "stateReason": "RESOLVED", + "isAnswered": true, + "answerChosenAt": "2024-06-01T12:00:00Z", + "author": { + "__typename": "User", + "login": "alice", + "id": "U1", + "name": "Alice" + }, + "category": { + "id": "C1", + "name": "Q&A", + "slug": "q-a", + "emoji": ":question:", + "isAnswerable": true + }, + "answerChosenBy": { + "__typename": "User", + "login": "bob", + "id": "U2", + "name": "Bob" + }, + "labels": { + "nodes": [ + {"id": "L1", "name": "bug", "color": "d73a4a"} + ] + }, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-02T00:00:00Z", + "closedAt": "2024-06-01T00:00:00Z", + "locked": true + } + `) + + emptyResp := searchResp(false, "", 0, "") + + tests := []struct { + name string + filters SearchFilters + after string + limit int + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantSingleDisc *Discussion + }{ + { + name: "maps all fields", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(searchResp(false, "", 1, richNode)), + ) + }, + wantTotal: 1, + wantLen: 1, + wantSingleDisc: &Discussion{ + ID: "D_rich1", + Number: 42, + Title: "Rich search result", + Body: "body text here", + URL: "https://github.com/OWNER/REPO/discussions/42", + Closed: true, + StateReason: "RESOLVED", + Author: DiscussionActor{ + ID: "U1", + Login: "alice", + Name: "Alice", + }, + Category: DiscussionCategory{ + ID: "C1", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{ + {ID: "L1", Name: "bug", Color: "d73a4a"}, + }, + Answered: true, + AnswerChosenAt: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC), + AnswerChosenBy: &DiscussionActor{ + ID: "U2", + Login: "bob", + Name: "Bob", + }, + Comments: DiscussionCommentList{}, + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC), + ClosedAt: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + Locked: true, + }, + }, + { + name: "limit zero", + limit: 0, + wantErr: "limit argument must be positive", + }, + { + name: "invalid orderBy", + limit: 10, + filters: SearchFilters{OrderBy: "bogus"}, + wantErr: "unknown order-by field", + }, + { + name: "invalid direction", + limit: 10, + filters: SearchFilters{Direction: "sideways"}, + wantErr: "unknown order direction", + }, + { + name: "invalid state", + limit: 10, + filters: SearchFilters{State: new("merged")}, + wantErr: "unknown state filter", + }, + { + name: "with after cursor", + limit: 10, + after: "someCursor", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Equal(t, "someCursor", vars["after"]) + }), + ) + }, + }, + { + name: "open state filter", + limit: 10, + filters: SearchFilters{State: new(FilterStateOpen)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "is:open") + }), + ) + }, + }, + { + name: "closed state filter", + limit: 10, + filters: SearchFilters{State: new(FilterStateClosed)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "is:closed") + }), + ) + }, + }, + { + name: "answered filter", + limit: 10, + filters: SearchFilters{Answered: new(true)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "is:answered") + }), + ) + }, + }, + { + name: "unanswered filter", + limit: 10, + filters: SearchFilters{Answered: new(false)}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "is:unanswered") + }), + ) + }, + }, + { + name: "author filter", + limit: 10, + filters: SearchFilters{Author: "alice"}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), `author:"alice"`) + }), + ) + }, + }, + { + name: "labels filter", + limit: 10, + filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + q := vars["query"].(string) + assert.Contains(t, q, `label:"bug"`) + assert.Contains(t, q, `label:"enhancement"`) + }), + ) + }, + }, + { + name: "category filter", + limit: 10, + filters: SearchFilters{Category: "Q&A"}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), `category:"Q&A"`) + }), + ) + }, + }, + { + name: "keywords filter", + limit: 10, + filters: SearchFilters{Keywords: "some keyword"}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "some keyword") + }), + ) + }, + }, + { + name: "order by created asc", + limit: 10, + filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "sort:created-asc") + }), + ) + }, + }, + { + name: "order by updated desc", + limit: 10, + filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { + assert.Contains(t, vars["query"].(string), "sort:updated-desc") + }), + ) + }, + }, + { + // When limit > 100, the first page requests 100 and the second page + // requests the remainder, exercising the per-iteration first variable. + name: "limit greater than 100", + limit: 101, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(searchResp(true, "pg2cursor", 101, minimalNodes(100)), func(_ string, vars map[string]interface{}) { + assert.Equal(t, float64(100), vars["first"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.GraphQLQuery(searchResp(false, "", 101, minimalNode("D101", "Discussion 101")), func(_ string, vars map[string]interface{}) { + assert.Equal(t, float64(1), vars["first"]) + }), + ) + }, + wantLen: 101, + wantTotal: 101, + }, + { + // When the page has more items than requested, NextCursor is set. + name: "pagination sets next cursor", + limit: 1, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(searchResp(true, "searchCursor42", 5, minimalNode("D1", "Discussion 1"))), + ) + }, + wantLen: 1, + wantTotal: 5, + wantCursor: "searchCursor42", + }, + { + // Two pages are fetched when limit exceeds the first page's results. + name: "pagination fetches multiple pages", + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(searchResp(true, "searchCursor1", 2, minimalNode("D1", "First"))), + ) + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(searchResp(false, "", 2, minimalNode("D2", "Second"))), + ) + }, + wantLen: 2, + wantTotal: 2, + wantTitles: []string{"First", "Second"}, + }, + { + name: "exact fit does not overfetch", + limit: 1, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(searchResp(false, "", 1, minimalNode("D1", "Only one"))), + ) + }, + wantLen: 1, + wantTotal: 1, + wantTitles: []string{"Only one"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + result, err := c.Search(repo, tt.filters, tt.after, tt.limit) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tt.wantTotal, result.TotalCount) + assert.Len(t, result.Discussions, tt.wantLen) + assert.Equal(t, tt.wantCursor, result.NextCursor) + + for i, title := range tt.wantTitles { + assert.Equal(t, title, result.Discussions[i].Title) + } + + if tt.wantSingleDisc != nil { + require.NotEmpty(t, result.Discussions) + assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) + } + }) + } +} + +func TestListCategories(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantCats []DiscussionCategory + }{ + { + name: "maps all fields", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCategoryList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled":true, + "discussionCategories":{"nodes":[ + {"id":"C1","name":"General","slug":"general","emoji":":speech_balloon:","isAnswerable":false}, + {"id":"C2","name":"Q&A","slug":"q-a","emoji":":question:","isAnswerable":true} + ]} + }}}`), + ) + }, + wantCats: []DiscussionCategory{ + {ID: "C1", Name: "General", Slug: "general", Emoji: ":speech_balloon:", IsAnswerable: false}, + {ID: "C2", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true}, + }, + }, + { + name: "discussions disabled", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCategoryList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled":false, + "discussionCategories":{"nodes":[]} + }}}`), + ) + }, + wantErr: "discussions disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + categories, err := c.ListCategories(repo) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.Len(t, categories, len(tt.wantCats)) + for i, want := range tt.wantCats { + assert.Equal(t, want, categories[i]) + } + }) + } +} + +func TestGetByNumber(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc *Discussion + }{ + { + name: "maps all fields", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 42, + "title": "Test Discussion", + "body": "This is a test", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": true, + "stateReason": "RESOLVED", + "isAnswered": true, + "answerChosenAt": "2025-06-01T12:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "C1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, + "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U2", "name": "Bob"}, + "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "closedAt": "2025-06-01T00:00:00Z", + "locked": true, + "comments": {"totalCount": 5} + } + } + } + } + `)), + ) + }, + assertDisc: &Discussion{ + ID: "D_1", + Number: 42, + Title: "Test Discussion", + Body: "This is a test", + URL: "https://github.com/OWNER/REPO/discussions/42", + Closed: true, + StateReason: "RESOLVED", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "C1", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, + Answered: true, + AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), + AnswerChosenBy: &DiscussionActor{ID: "U2", Login: "bob", Name: "Bob"}, + ReactionGroups: []ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 3}, + }, + Comments: DiscussionCommentList{TotalCount: 5}, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + Locked: true, + }, + }, + { + name: "discussions disabled", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": false, + "discussion": null + } + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository", "discussion"], + "message": "Could not resolve to a Discussion with the number of 42." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Discussion with the number of 42.", + }, + { + name: "repo not found", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository"], + "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetByNumber(repo, 42) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + assert.Equal(t, tt.assertDisc, d) + }) + } +} + +func TestGetWithComments(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + limit int + after string + newest bool + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc func(*testing.T, *Discussion) + }{ + { + name: "maps comments with replies", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 42, + "title": "Test Discussion", + "body": "Discussion body", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": true, + "stateReason": "RESOLVED", + "isAnswered": true, + "answerChosenAt": "2025-06-01T12:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U_alice", "name": "Alice"}, + "category": {"id": "CAT1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, + "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U_bob", "name": "Bob"}, + "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "closedAt": "2025-06-01T00:00:00Z", + "locked": true, + "comments": { + "totalCount": 1, + "pageInfo": {"endCursor": "COM_CUR", "hasNextPage": true, "startCursor": "COM_START", "hasPreviousPage": false}, + "nodes": [ + { + "id": "C1", + "url": "https://github.com/OWNER/REPO/discussions/42#comment-1", + "author": {"__typename": "User", "login": "octocat", "id": "U_octocat", "name": "Octocat"}, + "body": "Main comment", + "createdAt": "2025-03-01T00:00:00Z", + "isAnswer": true, + "upvoteCount": 5, + "reactionGroups": [{"content": "HEART", "users": {"totalCount": 2}}], + "replies": { + "totalCount": 1, + "nodes": [ + { + "id": "R1", + "url": "https://github.com/OWNER/REPO/discussions/42#reply-1", + "author": {"__typename": "User", "login": "hubot", "id": "U_hubot", "name": "Hubot"}, + "body": "Thanks!", + "createdAt": "2025-04-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 1, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 1}}] + } + ] + } + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, Discussion{ + ID: "D_1", + Number: 42, + Title: "Test Discussion", + Body: "Discussion body", + URL: "https://github.com/OWNER/REPO/discussions/42", + Closed: true, + StateReason: "RESOLVED", + Author: DiscussionActor{ID: "U_alice", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT1", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, + Answered: true, + AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), + AnswerChosenBy: &DiscussionActor{ID: "U_bob", Login: "bob", Name: "Bob"}, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 3}}, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + Locked: true, + Comments: DiscussionCommentList{ + TotalCount: 1, + NextCursor: "COM_CUR", + Direction: DiscussionCommentListDirectionForward, + Comments: []DiscussionComment{ + { + ID: "C1", + URL: "https://github.com/OWNER/REPO/discussions/42#comment-1", + Author: DiscussionActor{ID: "U_octocat", Login: "octocat", Name: "Octocat"}, + Body: "Main comment", + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + UpvoteCount: 5, + ReactionGroups: []ReactionGroup{{Content: "HEART", TotalCount: 2}}, + Replies: DiscussionCommentList{ + TotalCount: 1, + Direction: DiscussionCommentListDirectionBackward, + Comments: []DiscussionComment{ + { + ID: "R1", + URL: "https://github.com/OWNER/REPO/discussions/42#reply-1", + Author: DiscussionActor{ID: "U_hubot", Login: "hubot", Name: "Hubot"}, + Body: "Thanks!", + CreatedAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), + UpvoteCount: 1, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 1}}, + }, + }, + }, + }, + }, + }, + }, *d) + }, + }, + { + name: "pagination forward", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 3, + "pageInfo": {"endCursor": "CUR_B", "hasNextPage": true, "startCursor": "", "hasPreviousPage": false}, + "nodes": [ + { + "id": "C1", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Hello", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 1) + assert.Equal(t, 3, comments.TotalCount) + assert.Equal(t, "CUR_A", comments.Cursor) + assert.Equal(t, "CUR_B", comments.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) + }, + }, + { + name: "pagination backward newest", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 5, + "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_Y", "hasPreviousPage": true}, + "nodes": [ + { + "id": "C1", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "First", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + }, + { + "id": "C2", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Second", + "createdAt": "2025-01-02T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 2) + assert.Equal(t, 5, comments.TotalCount) + assert.Equal(t, "CUR_X", comments.Cursor) + assert.Equal(t, "CUR_Y", comments.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionBackward, comments.Direction) + assert.Equal(t, "C2", comments.Comments[0].ID, "newest mode should reverse comments") + assert.Equal(t, "C1", comments.Comments[1].ID) + }, + }, + { + name: "no more pages", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 1, + "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "", "hasPreviousPage": false}, + "nodes": [ + { + "id": "C1", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Only one", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 1) + assert.Equal(t, 1, comments.TotalCount) + assert.Equal(t, "", comments.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) + }, + }, + { + name: "discussions disabled", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": false, + "discussion": null + } + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository", "discussion"], + "message": "Could not resolve to a Discussion with the number of 1." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Discussion", + }, + { + name: "repo not found", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository"], + "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", + }, + { + name: "empty comments", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 0, + "pageInfo": {"endCursor": null, "hasNextPage": false, "startCursor": null, "hasPreviousPage": false}, + "nodes": [] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 0) + assert.Equal(t, 0, comments.TotalCount) + assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) + }, + }, + { + name: "first page newest reverses comments", + limit: 5, + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 8, + "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_START", "hasPreviousPage": true}, + "nodes": [ + { + "id": "C4", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Fourth", + "createdAt": "2025-01-04T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + }, + { + "id": "C5", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Fifth", + "createdAt": "2025-01-05T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": 0, "nodes": []} + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 2) + assert.Equal(t, 8, comments.TotalCount) + assert.Equal(t, "", comments.Cursor) + assert.Equal(t, "CUR_START", comments.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionBackward, comments.Direction) + assert.Equal(t, "C5", comments.Comments[0].ID, "newest mode should reverse comments") + assert.Equal(t, "C4", comments.Comments[1].ID) + }, + }, + { + name: "multiple replies on comment", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": { + "totalCount": 1, + "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "", "hasPreviousPage": false}, + "nodes": [ + { + "id": "C1", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Parent", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 3, + "nodes": [ + { + "id": "R1", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "First reply", + "createdAt": "2025-01-02T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + }, + { + "id": "R2", + "url": "", + "author": {"__typename": "User", "login": "carol"}, + "body": "Second reply", + "createdAt": "2025-01-03T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + }, + { + "id": "R3", + "url": "", + "author": {"__typename": "User", "login": "dave"}, + "body": "Third reply", + "createdAt": "2025-01-04T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + ] + } + } + ] + } + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + comments := d.Comments + assert.Len(t, comments.Comments, 1) + assert.Equal(t, 1, comments.TotalCount) + assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) + c := comments.Comments[0] + require.Len(t, c.Replies.Comments, 3) + assert.Equal(t, 3, c.Replies.TotalCount) + assert.Equal(t, "R1", c.Replies.Comments[0].ID) + assert.Equal(t, "R2", c.Replies.Comments[1].ID) + assert.Equal(t, "R3", c.Replies.Comments[2].ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetWithComments(repo, 1, tt.limit, tt.after, tt.newest) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + tt.assertDisc(t, d) + }) + } +} + +func TestGetCommentReplies(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + commentID string + limit int + after string + newest bool + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc func(*testing.T, *Discussion) + }{ + { + name: "maps all fields", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 42, + "title": "Test Discussion", + "body": "Discussion body", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": true, + "stateReason": "RESOLVED", + "isAnswered": true, + "answerChosenAt": "2025-06-01T12:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U_alice", "name": "Alice"}, + "category": {"id": "CAT1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, + "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U_bob", "name": "Bob"}, + "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "closedAt": "2025-06-01T00:00:00Z", + "locked": true + } + }, + "node": { + "id": "DC_abc", + "url": "https://github.com/OWNER/REPO/discussions/42#discussioncomment-1", + "author": {"__typename": "User", "login": "octocat", "id": "U_octocat", "name": "Octocat"}, + "body": "Top-level comment", + "createdAt": "2025-03-01T00:00:00Z", + "isAnswer": true, + "upvoteCount": 5, + "reactionGroups": [{"content": "HEART", "users": {"totalCount": 2}}], + "replies": { + "totalCount": 1, + "pageInfo": {"endCursor": "REP_CUR", "hasNextPage": true, "startCursor": "REP_START", "hasPreviousPage": false}, + "nodes": [ + { + "id": "R1", + "url": "https://github.com/OWNER/REPO/discussions/42#discussioncomment-2", + "author": {"__typename": "User", "login": "hubot", "id": "U_hubot", "name": "Hubot"}, + "body": "A reply", + "createdAt": "2025-04-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 1, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 1}}] + } + ] + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, Discussion{ + ID: "D_1", + Number: 42, + Title: "Test Discussion", + Body: "Discussion body", + URL: "https://github.com/OWNER/REPO/discussions/42", + Closed: true, + StateReason: "RESOLVED", + Author: DiscussionActor{ID: "U_alice", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT1", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, + Answered: true, + AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), + AnswerChosenBy: &DiscussionActor{ID: "U_bob", Login: "bob", Name: "Bob"}, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 3}}, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + Locked: true, + Comments: DiscussionCommentList{ + TotalCount: 1, + Comments: []DiscussionComment{ + { + ID: "DC_abc", + URL: "https://github.com/OWNER/REPO/discussions/42#discussioncomment-1", + Author: DiscussionActor{ID: "U_octocat", Login: "octocat", Name: "Octocat"}, + Body: "Top-level comment", + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + UpvoteCount: 5, + ReactionGroups: []ReactionGroup{{Content: "HEART", TotalCount: 2}}, + Replies: DiscussionCommentList{ + TotalCount: 1, + NextCursor: "REP_CUR", + Direction: DiscussionCommentListDirectionForward, + Comments: []DiscussionComment{ + { + ID: "R1", + URL: "https://github.com/OWNER/REPO/discussions/42#discussioncomment-2", + Author: DiscussionActor{ID: "U_hubot", Login: "hubot", Name: "Hubot"}, + Body: "A reply", + CreatedAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), + UpvoteCount: 1, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 1}}, + }, + }, + }, + }, + }, + }, + }, *d) + }, + }, + { + name: "pagination forward oldest", + commentID: "DC_abc", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": { + "id": "DC_abc", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Comment", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 3, + "pageInfo": {"endCursor": "CUR_B", "hasNextPage": true, "startCursor": "CUR_A", "hasPreviousPage": false}, + "nodes": [ + { + "id": "R1", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Reply 1", + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + }, + { + "id": "R2", + "url": "", + "author": {"__typename": "User", "login": "carol"}, + "body": "Reply 2", + "createdAt": "2025-03-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + ] + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies + assert.Len(t, replies.Comments, 2) + assert.Equal(t, 3, replies.TotalCount) + assert.Equal(t, "CUR_A", replies.Cursor) + assert.Equal(t, "CUR_B", replies.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionForward, replies.Direction) + assert.Equal(t, "R1", replies.Comments[0].ID, "forward mode should preserve chronological order") + assert.Equal(t, "R2", replies.Comments[1].ID) + }, + }, + { + name: "pagination backward newest reverses replies", + commentID: "DC_abc", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": { + "id": "DC_abc", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Comment", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 5, + "pageInfo": {"endCursor": "CUR_END", "hasNextPage": false, "startCursor": "CUR_Y", "hasPreviousPage": true}, + "nodes": [ + { + "id": "R1", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Older", + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + }, + { + "id": "R2", + "url": "", + "author": {"__typename": "User", "login": "carol"}, + "body": "Newer", + "createdAt": "2025-03-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + ] + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies + assert.Len(t, replies.Comments, 2) + assert.Equal(t, 5, replies.TotalCount) + assert.Equal(t, "CUR_X", replies.Cursor) + assert.Equal(t, "CUR_Y", replies.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionBackward, replies.Direction) + assert.Equal(t, "R2", replies.Comments[0].ID, "newest mode should reverse replies") + assert.Equal(t, "R1", replies.Comments[1].ID) + }, + }, + { + name: "first page newest reverses replies", + commentID: "DC_abc", + limit: 5, + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": { + "id": "DC_abc", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Comment", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 3, + "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_START", "hasPreviousPage": true}, + "nodes": [ + { + "id": "R1", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Older", + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + }, + { + "id": "R2", + "url": "", + "author": {"__typename": "User", "login": "carol"}, + "body": "Newer", + "createdAt": "2025-03-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + ] + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies + assert.Len(t, replies.Comments, 2) + assert.Equal(t, 3, replies.TotalCount) + assert.Equal(t, "", replies.Cursor) + assert.Equal(t, "CUR_START", replies.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionBackward, replies.Direction) + assert.Equal(t, "R2", replies.Comments[0].ID, "newest mode should reverse replies") + assert.Equal(t, "R1", replies.Comments[1].ID) + }, + }, + { + name: "no more pages", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": { + "id": "DC_abc", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Comment", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 1, + "pageInfo": {"endCursor": "CUR_ONLY", "hasNextPage": false, "startCursor": "CUR_ONLY", "hasPreviousPage": false}, + "nodes": [ + { + "id": "R1", + "url": "", + "author": {"__typename": "User", "login": "bob"}, + "body": "Only reply", + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + ] + } + } + } + } + `)), + ) + }, + assertDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies + assert.Len(t, replies.Comments, 1) + assert.Equal(t, 1, replies.TotalCount) + assert.Equal(t, "", replies.NextCursor) + assert.Equal(t, DiscussionCommentListDirectionForward, replies.Direction) + }, + }, + { + name: "discussions disabled", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": false, + "discussion": null + }, + "node": { + "id": "DC_abc", + "url": "", + "author": {"__typename": "User", "login": "alice"}, + "body": "Comment", + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [], + "replies": { + "totalCount": 0, + "pageInfo": {"endCursor": null, "hasNextPage": false, "startCursor": null, "hasPreviousPage": false}, + "nodes": [] + } + } + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository", "discussion"], + "message": "Could not resolve to a Discussion with the number of 1." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Discussion", + }, + { + name: "repo not found", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": null, + "node": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository"], + "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Repository", + }, + { + name: "reply node not found", + commentID: "DC_invalid", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["node"], + "message": "Could not resolve to a node with the global id of 'DC_invalid'" + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a node", + }, + { + name: "node is not a discussion comment", + commentID: "I_notacomment", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": true, + "discussion": { + "id": "D_1", + "number": 1, + "title": "Test", + "body": "", + "url": "", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + }, + "node": {} + } + } + `)), + ) + }, + wantErr: "node I_notacomment is not a discussion comment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetCommentReplies(repo, 1, tt.commentID, tt.limit, tt.after, tt.newest) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.Len(t, d.Comments.Comments, 1, "GetCommentReplies should return exactly one comment") + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + tt.assertDisc(t, d) + }) + } +} + +func repoMetaResp(id string, discussionsEnabled bool) string { + return fmt.Sprintf(`{ + "data": { + "repository": { + "id": %q, + "hasDiscussionsEnabled": %t + } + } + }`, id, discussionsEnabled) +} + +func TestCreate(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + input CreateDiscussionInput + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc *Discussion + }{ + { + name: "maps all fields", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(repoMetaResp("R_1", true)), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation CreateDiscussion\b`, func(input map[string]interface{}) bool { + assert.Equal(t, "R_1", input["repositoryId"]) + assert.Equal(t, "CAT_1", input["categoryId"]) + assert.Equal(t, "New Discussion", input["title"]) + assert.Equal(t, "Discussion body", input["body"]) + return true + }), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "createDiscussion": { + "discussion": { + "id": "D_new", + "number": 99, + "title": "New Discussion", + "body": "Discussion body", + "url": "https://github.com/OWNER/REPO/discussions/99", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 0}}], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + assertDisc: &Discussion{ + ID: "D_new", + Number: 99, + Title: "New Discussion", + Body: "Discussion body", + URL: "https://github.com/OWNER/REPO/discussions/99", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT_1", + Name: "General", + Slug: "general", + Emoji: ":speech_balloon:", + }, + Labels: []DiscussionLabel{}, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 0}}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }, + { + name: "discussions disabled", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(repoMetaResp("R_1", false)), + ) + }, + wantErr: "has discussions disabled", + }, + { + name: "repo not found", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository"], + "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", + }, + { + name: "mutation error", + input: CreateDiscussionInput{ + CategoryID: "BAD_CAT", + Title: "Test", + Body: "Body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(repoMetaResp("R_1", true)), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "createDiscussion": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "message": "Could not resolve to a node with the global id of 'BAD_CAT'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.", + }, + { + name: "creates discussion with labels via addLabels mutation", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + LabelIDs: []string{"L_bug", "L_enh"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(repoMetaResp("R_1", true)), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "createDiscussion": { + "discussion": { + "id": "D_new", + "number": 99, + "title": "New Discussion", + "body": "Discussion body", + "url": "https://github.com/OWNER/REPO/discussions/99", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 0}}], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddLabelsToDiscussion\b`, func(input map[string]interface{}) bool { + assert.Equal(t, "D_new", input["labelableId"]) + labelIDs, ok := input["labelIds"].([]interface{}) + assert.True(t, ok) + assert.Equal(t, []interface{}{"L_bug", "L_enh"}, labelIDs) + return true + }), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "addLabelsToLabelable": { + "labelable": { + "id": "D_new", + "number": 99, + "title": "New Discussion", + "body": "Discussion body", + "url": "https://github.com/OWNER/REPO/discussions/99", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"}, + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"} + ] + }, + "reactionGroups": [{"content": "THUMBS_UP","users": {"totalCount": 0}}], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + assertDisc: &Discussion{ + ID: "D_new", + Number: 99, + Title: "New Discussion", + Body: "Discussion body", + URL: "https://github.com/OWNER/REPO/discussions/99", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT_1", + Name: "General", + Slug: "general", + Emoji: ":speech_balloon:", + }, + Labels: []DiscussionLabel{ + {ID: "L_bug", Name: "bug", Color: "d73a4a"}, + {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}, + }, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 0}}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }, + { + name: "add labels mutation failure returns error", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + LabelIDs: []string{"L_bug"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryMeta\b`), + httpmock.StringResponse(repoMetaResp("R_1", true)), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "createDiscussion": { + "discussion": { + "id": "D_new", + "number": 99, + "title": "Test", + "body": "Body", + "url": "https://github.com/OWNER/REPO/discussions/99", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": null, + "errors": [{"message": "could not apply labels"}] + } + `)), + ) + }, + wantErr: "could not apply labels", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.Create(repo, tt.input) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + assert.Equal(t, tt.assertDisc, d) + }) + } +} + +func TestListLabels(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + want []DiscussionLabel + wantErr string + }{ + { + name: "single page", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"}, + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + }, + want: []DiscussionLabel{ + {ID: "L_bug", Name: "bug", Color: "d73a4a"}, + {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}, + }, + }, + { + name: "multiple pages", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ], + "pageInfo": {"hasNextPage": true, "endCursor": "CUR_1"} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + }, + want: []DiscussionLabel{ + {ID: "L_bug", Name: "bug", Color: "d73a4a"}, + {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}, + }, + }, + { + name: "empty repository", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + }, + want: nil, + }, + { + name: "query error", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), + httpmock.StringResponse(`{"data":null,"errors":[{"message":"something went wrong"}]}`), + ) + }, + wantErr: "something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + tt.httpStubs(reg) + + client := newTestDiscussionClient(reg).(*discussionClient) + labels, err := client.ListLabels(repo) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, labels) + }) + } +} + +func TestEditDiscussionLabels(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + baseNode := func() discussionListNode { + return discussionListNode{ + ID: "D_1", + Number: 5, + Title: "T", + Body: "B", + URL: "https://github.com/OWNER/REPO/discussions/5", + Author: actorNode{ + TypeName: "User", + Login: "alice", + User: struct{ ID, Name string }{ID: "U1", Name: "Alice"}, + Bot: struct{ ID string }{ID: "U1"}, + }, + Category: struct { + ID string + Name string + Slug string + Emoji string + IsAnswerable bool + }{ID: "CAT_1", Name: "General", Slug: "general"}, + ReactionGroups: []struct { + Content string + Users struct{ TotalCount int } + }{}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + } + } + + tests := []struct { + name string + addIDs []string + removeIDs []string + setupMock func(reg *httpmock.Registry) + wantErr string + wantNode func() discussionListNode + }{ + { + name: "adds and removes labels", + addIDs: []string{"L_bug", "L_enh"}, + removeIDs: []string{"L_old"}, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation RemoveLabelsFromDiscussion\b`, func(input map[string]interface{}) bool { + assert.Equal(t, "D_1", input["labelableId"]) + assert.Equal(t, []interface{}{"L_old"}, input["labelIds"]) + return true + }), + // This response is superseded by the subsequent add mutation so we don't need all fields. + httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"labelable":{"id": "D_1"}}}}`), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddLabelsToDiscussion\b`, func(input map[string]interface{}) bool { + assert.Equal(t, "D_1", input["labelableId"]) + assert.Equal(t, []interface{}{"L_bug", "L_enh"}, input["labelIds"]) + return true + }), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "addLabelsToLabelable": { + "labelable": { + "id": "D_1", + "number": 5, + "title": "T", + "body": "B", + "url": "https://github.com/OWNER/REPO/discussions/5", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"}, + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"} + ] + }, + "reactionGroups": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + wantNode: func() discussionListNode { + n := baseNode() + n.Labels.Nodes = []struct { + ID string + Name string + Color string + }{ + {ID: "L_bug", Name: "bug", Color: "d73a4a"}, + {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}, + } + return n + }, + }, + { + name: "only adds labels", + addIDs: []string{"L_bug"}, + removeIDs: nil, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "addLabelsToLabelable": { + "labelable": { + "id": "D_1", + "number": 5, + "title": "T", + "body": "B", + "url": "https://github.com/OWNER/REPO/discussions/5", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ] + }, + "reactionGroups": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + wantNode: func() discussionListNode { + n := baseNode() + n.Labels.Nodes = []struct { + ID string + Name string + Color string + }{ + {ID: "L_bug", Name: "bug", Color: "d73a4a"}, + } + return n + }, + }, + { + name: "only removes labels", + addIDs: nil, + removeIDs: []string{"L_old"}, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "removeLabelsFromLabelable": { + "labelable": { + "id": "D_1", + "number": 5, + "title": "T", + "body": "B", + "url": "https://github.com/OWNER/REPO/discussions/5", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": { + "nodes": [] + }, + "reactionGroups": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + wantNode: func() discussionListNode { + n := baseNode() + n.Labels.Nodes = []struct { + ID string + Name string + Color string + }{} + return n + }, + }, + { + name: "skips both when empty", + addIDs: nil, + removeIDs: nil, + setupMock: func(reg *httpmock.Registry) {}, + }, + { + name: "remove error stops before add", + addIDs: []string{"L_bug"}, + removeIDs: []string{"L_old"}, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), + httpmock.StringResponse(`{"data":null,"errors":[{"message":"could not remove labels"}]}`), + ) + }, + wantErr: "could not remove labels", + }, + { + name: "add error is returned", + addIDs: []string{"L_bug"}, + removeIDs: nil, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":null,"errors":[{"message":"could not add labels"}]}`), + ) + }, + wantErr: "could not add labels", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + tt.setupMock(reg) + + client := newTestDiscussionClient(reg).(*discussionClient) + + node, err := client.editDiscussionLabels(repo, "D_1", tt.addIDs, tt.removeIDs) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantNode == nil { + assert.Nil(t, node) + } else { + require.NotNil(t, node) + assert.Equal(t, tt.wantNode(), *node) + } + }) + } +} + +func TestUpdate(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + titleStr := "Updated title" + bodyStr := "Updated body" + catID := "CAT_2" + + tests := []struct { + name string + input UpdateDiscussionInput + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc *Discussion + }{ + { + name: "nothing to update", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + }, + wantErr: "nothing to update", + }, + { + name: "maps all fields", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + Title: &titleStr, + Body: &bodyStr, + CategoryID: &catID, + AddLabelIDs: []string{"L_bug", "L_enh"}, + RemoveLabelIDs: []string{"L_old", "L_stale"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "updateDiscussion": { + "discussion": { + "id": "D_1", + "number": 5, + "title": "Updated title", + "body": "Updated body", + "url": "https://github.com/OWNER/REPO/discussions/5", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_2", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, + "answerChosenBy": null, + "labels": {"nodes": [{"id": "L_bug", "name": "bug", "color": "d73a4a"}, {"id": "L_enh", "name": "enhancement", "color": "a2eeef"}]}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 0}}], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), + httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"labelable":{"id":"D_1","number":5,"title":"Updated title","body":"Updated body","url":"https://github.com/OWNER/REPO/discussions/5","closed":false,"stateReason":"","isAnswered":false,"answerChosenAt":"0001-01-01T00:00:00Z","author":{"__typename":"User","login":"alice","id":"U1","name":"Alice"},"category":{"id":"CAT_2","name":"Q&A","slug":"q-a","emoji":":question:","isAnswerable":true},"answerChosenBy":null,"labels":{"nodes":[]},"reactionGroups":[{"content":"THUMBS_UP","users":{"totalCount":0}}],"createdAt":"2025-06-01T00:00:00Z","updatedAt":"2025-06-02T00:00:00Z","closedAt":"0001-01-01T00:00:00Z","locked":false}}}}`), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"labelable":{"id":"D_1","number":5,"title":"Updated title","body":"Updated body","url":"https://github.com/OWNER/REPO/discussions/5","closed":false,"stateReason":"","isAnswered":false,"answerChosenAt":"0001-01-01T00:00:00Z","author":{"__typename":"User","login":"alice","id":"U1","name":"Alice"},"category":{"id":"CAT_2","name":"Q&A","slug":"q-a","emoji":":question:","isAnswerable":true},"answerChosenBy":null,"labels":{"nodes":[{"id":"L_bug","name":"bug","color":"d73a4a"},{"id":"L_enh","name":"enhancement","color":"a2eeef"}]},"reactionGroups":[{"content":"THUMBS_UP","users":{"totalCount":0}}],"createdAt":"2025-06-01T00:00:00Z","updatedAt":"2025-06-02T00:00:00Z","closedAt":"0001-01-01T00:00:00Z","locked":false}}}}`), + ) + }, + assertDisc: &Discussion{ + ID: "D_1", + Number: 5, + Title: "Updated title", + Body: "Updated body", + URL: "https://github.com/OWNER/REPO/discussions/5", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT_2", + Name: "Q&A", + Slug: "q-a", + Emoji: ":question:", + IsAnswerable: true, + }, + Labels: []DiscussionLabel{{ID: "L_bug", Name: "bug", Color: "d73a4a"}, {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}}, + ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 0}}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 2, 0, 0, 0, 0, time.UTC), + }, + }, + { + name: "partial update title only", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + Title: &titleStr, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "updateDiscussion": { + "discussion": { + "id": "D_1", + "number": 5, + "title": "Updated title", + "body": "Original body", + "url": "https://github.com/OWNER/REPO/discussions/5", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), + ) + }, + assertDisc: &Discussion{ + ID: "D_1", + Number: 5, + Title: "Updated title", + Body: "Original body", + URL: "https://github.com/OWNER/REPO/discussions/5", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT_1", + Name: "General", + Slug: "general", + Emoji: ":speech_balloon:", + }, + Labels: []DiscussionLabel{}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 2, 0, 0, 0, 0, time.UTC), + }, + }, + { + name: "mutation error", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + Title: &titleStr, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation UpdateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "updateDiscussion": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "message": "Could not resolve to a Discussion with the global id of 'D_1'." + } + ] + } + `)), + ) + }, + wantErr: "Could not resolve to a Discussion with the global id of 'D_1'.", + }, + { + name: "label only update", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + AddLabelIDs: []string{"L_bug", "L_enh"}, + RemoveLabelIDs: []string{"L_old", "L_stale"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), + httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"labelable":{"id":"D_1","number":5,"title":"T","body":"B","url":"https://github.com/OWNER/REPO/discussions/5","closed":false,"stateReason":"","isAnswered":false,"answerChosenAt":"0001-01-01T00:00:00Z","author":{"__typename":"User","login":"alice","id":"U1","name":"Alice"},"category":{"id":"CAT_1","name":"General","slug":"general","emoji":"","isAnswerable":false},"answerChosenBy":null,"labels":{"nodes":[]},"reactionGroups":[],"createdAt":"2025-06-01T00:00:00Z","updatedAt":"2025-06-01T00:00:00Z","closedAt":"0001-01-01T00:00:00Z","locked":false}}}}`), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"labelable":{"id":"D_1","number":5,"title":"T","body":"B","url":"https://github.com/OWNER/REPO/discussions/5","closed":false,"stateReason":"","isAnswered":false,"answerChosenAt":"0001-01-01T00:00:00Z","author":{"__typename":"User","login":"alice","id":"U1","name":"Alice"},"category":{"id":"CAT_1","name":"General","slug":"general","emoji":"","isAnswerable":false},"answerChosenBy":null,"labels":{"nodes":[{"id":"L_bug","name":"bug","color":"d73a4a"},{"id":"L_enh","name":"enhancement","color":"a2eeef"}]},"reactionGroups":[],"createdAt":"2025-06-01T00:00:00Z","updatedAt":"2025-06-01T00:00:00Z","closedAt":"0001-01-01T00:00:00Z","locked":false}}}}`), + ) + }, + assertDisc: &Discussion{ + ID: "D_1", + Number: 5, + Title: "T", + Body: "B", + URL: "https://github.com/OWNER/REPO/discussions/5", + Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, + Category: DiscussionCategory{ + ID: "CAT_1", + Name: "General", + Slug: "general", + }, + Labels: []DiscussionLabel{{ID: "L_bug", Name: "bug", Color: "d73a4a"}, {ID: "L_enh", Name: "enhancement", Color: "a2eeef"}}, + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.Update(repo, tt.input) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + assert.Equal(t, tt.assertDisc, d) + }) + } +} diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go new file mode 100644 index 00000000000..075c2f89ccd --- /dev/null +++ b/pkg/cmd/discussion/client/client_mock.go @@ -0,0 +1,909 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package client + +import ( + "github.com/cli/cli/v2/internal/ghrepo" + "sync" +) + +// Ensure, that DiscussionClientMock does implement DiscussionClient. +// If this is not the case, regenerate this file with moq. +var _ DiscussionClient = &DiscussionClientMock{} + +// DiscussionClientMock is a mock implementation of DiscussionClient. +// +// func TestSomethingThatUsesDiscussionClient(t *testing.T) { +// +// // make and configure a mocked DiscussionClient +// mockedDiscussionClient := &DiscussionClientMock{ +// AddCommentFunc: func(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) { +// panic("mock out the AddComment method") +// }, +// CloseFunc: func(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) { +// panic("mock out the Close method") +// }, +// CreateFunc: func(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { +// panic("mock out the Create method") +// }, +// 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") +// }, +// ListFunc: func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) { +// panic("mock out the List method") +// }, +// ListCategoriesFunc: func(repo ghrepo.Interface) ([]DiscussionCategory, error) { +// panic("mock out the ListCategories method") +// }, +// ListLabelsFunc: func(repo ghrepo.Interface) ([]DiscussionLabel, error) { +// panic("mock out the ListLabels method") +// }, +// LockFunc: func(repo ghrepo.Interface, id string, reason string) error { +// panic("mock out the Lock method") +// }, +// MarkAnswerFunc: func(repo ghrepo.Interface, commentID string) error { +// panic("mock out the MarkAnswer method") +// }, +// ReopenFunc: func(repo ghrepo.Interface, id string) (*Discussion, error) { +// panic("mock out the Reopen method") +// }, +// SearchFunc: func(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) { +// panic("mock out the Search method") +// }, +// UnlockFunc: func(repo ghrepo.Interface, id string) error { +// panic("mock out the Unlock method") +// }, +// UnmarkAnswerFunc: func(repo ghrepo.Interface, commentID string) error { +// panic("mock out the UnmarkAnswer method") +// }, +// UpdateFunc: func(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { +// panic("mock out the Update method") +// }, +// } +// +// // use mockedDiscussionClient in code that requires DiscussionClient +// // and then make assertions. +// +// } +type DiscussionClientMock struct { + // AddCommentFunc mocks the AddComment method. + AddCommentFunc func(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) + + // CloseFunc mocks the Close method. + CloseFunc func(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) + + // CreateFunc mocks the Create method. + CreateFunc func(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) + + // 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) + + // ListFunc mocks the List method. + ListFunc func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) + + // ListCategoriesFunc mocks the ListCategories method. + ListCategoriesFunc func(repo ghrepo.Interface) ([]DiscussionCategory, error) + + // ListLabelsFunc mocks the ListLabels method. + ListLabelsFunc func(repo ghrepo.Interface) ([]DiscussionLabel, error) + + // LockFunc mocks the Lock method. + LockFunc func(repo ghrepo.Interface, id string, reason string) error + + // MarkAnswerFunc mocks the MarkAnswer method. + MarkAnswerFunc func(repo ghrepo.Interface, commentID string) error + + // ReopenFunc mocks the Reopen method. + ReopenFunc func(repo ghrepo.Interface, id string) (*Discussion, error) + + // SearchFunc mocks the Search method. + SearchFunc func(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) + + // UnlockFunc mocks the Unlock method. + UnlockFunc func(repo ghrepo.Interface, id string) error + + // UnmarkAnswerFunc mocks the UnmarkAnswer method. + UnmarkAnswerFunc func(repo ghrepo.Interface, commentID string) error + + // UpdateFunc mocks the Update method. + UpdateFunc func(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) + + // calls tracks calls to the methods. + calls struct { + // AddComment holds details about calls to the AddComment method. + AddComment []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // DiscussionID is the discussionID argument value. + DiscussionID string + // Body is the body argument value. + Body string + // ReplyToID is the replyToID argument value. + ReplyToID string + } + // Close holds details about calls to the Close method. + Close []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // ID is the id argument value. + ID string + // Reason is the reason argument value. + Reason CloseReason + } + // Create holds details about calls to the Create method. + Create []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Input is the input argument value. + Input CreateDiscussionInput + } + // GetByNumber holds details about calls to the GetByNumber method. + GetByNumber []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // 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. + Repo ghrepo.Interface + // Number is the number argument value. + Number int + // CommentLimit is the commentLimit argument value. + CommentLimit int + // After is the after argument value. + After string + // Newest is the newest argument value. + Newest bool + } + // List holds details about calls to the List method. + List []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Filters is the filters argument value. + Filters ListFilters + // After is the after argument value. + After string + // Limit is the limit argument value. + Limit int + } + // ListCategories holds details about calls to the ListCategories method. + ListCategories []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + } + // ListLabels holds details about calls to the ListLabels method. + ListLabels []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + } + // Lock holds details about calls to the Lock method. + Lock []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // ID is the id argument value. + ID string + // Reason is the reason argument value. + Reason string + } + // MarkAnswer holds details about calls to the MarkAnswer method. + MarkAnswer []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // CommentID is the commentID argument value. + CommentID string + } + // Reopen holds details about calls to the Reopen method. + Reopen []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // ID is the id argument value. + ID string + } + // Search holds details about calls to the Search method. + Search []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Filters is the filters argument value. + Filters SearchFilters + // After is the after argument value. + After string + // Limit is the limit argument value. + Limit int + } + // Unlock holds details about calls to the Unlock method. + Unlock []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // ID is the id argument value. + ID string + } + // UnmarkAnswer holds details about calls to the UnmarkAnswer method. + UnmarkAnswer []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // CommentID is the commentID argument value. + CommentID string + } + // Update holds details about calls to the Update method. + Update []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Input is the input argument value. + Input UpdateDiscussionInput + } + } + lockAddComment sync.RWMutex + lockClose sync.RWMutex + lockCreate sync.RWMutex + lockGetByNumber sync.RWMutex + lockGetCommentReplies sync.RWMutex + lockGetWithComments sync.RWMutex + lockList sync.RWMutex + lockListCategories sync.RWMutex + lockListLabels 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. +func (mock *DiscussionClientMock) AddComment(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) { + if mock.AddCommentFunc == nil { + panic("DiscussionClientMock.AddCommentFunc: method is nil but DiscussionClient.AddComment was just called") + } + callInfo := struct { + Repo ghrepo.Interface + DiscussionID string + Body string + ReplyToID string + }{ + Repo: repo, + DiscussionID: discussionID, + Body: body, + ReplyToID: replyToID, + } + mock.lockAddComment.Lock() + mock.calls.AddComment = append(mock.calls.AddComment, callInfo) + mock.lockAddComment.Unlock() + return mock.AddCommentFunc(repo, discussionID, body, replyToID) +} + +// AddCommentCalls gets all the calls that were made to AddComment. +// Check the length with: +// +// len(mockedDiscussionClient.AddCommentCalls()) +func (mock *DiscussionClientMock) AddCommentCalls() []struct { + Repo ghrepo.Interface + DiscussionID string + Body string + ReplyToID string +} { + var calls []struct { + Repo ghrepo.Interface + DiscussionID string + Body string + ReplyToID string + } + mock.lockAddComment.RLock() + calls = mock.calls.AddComment + mock.lockAddComment.RUnlock() + return calls +} + +// Close calls CloseFunc. +func (mock *DiscussionClientMock) Close(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) { + if mock.CloseFunc == nil { + panic("DiscussionClientMock.CloseFunc: method is nil but DiscussionClient.Close was just called") + } + callInfo := struct { + Repo ghrepo.Interface + ID string + Reason CloseReason + }{ + Repo: repo, + ID: id, + Reason: reason, + } + mock.lockClose.Lock() + mock.calls.Close = append(mock.calls.Close, callInfo) + mock.lockClose.Unlock() + return mock.CloseFunc(repo, id, reason) +} + +// CloseCalls gets all the calls that were made to Close. +// Check the length with: +// +// len(mockedDiscussionClient.CloseCalls()) +func (mock *DiscussionClientMock) CloseCalls() []struct { + Repo ghrepo.Interface + ID string + Reason CloseReason +} { + var calls []struct { + Repo ghrepo.Interface + ID string + Reason CloseReason + } + mock.lockClose.RLock() + calls = mock.calls.Close + mock.lockClose.RUnlock() + return calls +} + +// Create calls CreateFunc. +func (mock *DiscussionClientMock) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { + if mock.CreateFunc == nil { + panic("DiscussionClientMock.CreateFunc: method is nil but DiscussionClient.Create was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Input CreateDiscussionInput + }{ + Repo: repo, + Input: input, + } + mock.lockCreate.Lock() + mock.calls.Create = append(mock.calls.Create, callInfo) + mock.lockCreate.Unlock() + return mock.CreateFunc(repo, input) +} + +// CreateCalls gets all the calls that were made to Create. +// Check the length with: +// +// len(mockedDiscussionClient.CreateCalls()) +func (mock *DiscussionClientMock) CreateCalls() []struct { + Repo ghrepo.Interface + Input CreateDiscussionInput +} { + var calls []struct { + Repo ghrepo.Interface + Input CreateDiscussionInput + } + mock.lockCreate.RLock() + calls = mock.calls.Create + mock.lockCreate.RUnlock() + return calls +} + +// GetByNumber calls GetByNumberFunc. +func (mock *DiscussionClientMock) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) { + if mock.GetByNumberFunc == nil { + panic("DiscussionClientMock.GetByNumberFunc: method is nil but DiscussionClient.GetByNumber was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Number int + }{ + Repo: repo, + Number: number, + } + mock.lockGetByNumber.Lock() + mock.calls.GetByNumber = append(mock.calls.GetByNumber, callInfo) + mock.lockGetByNumber.Unlock() + return mock.GetByNumberFunc(repo, number) +} + +// GetByNumberCalls gets all the calls that were made to GetByNumber. +// Check the length with: +// +// len(mockedDiscussionClient.GetByNumberCalls()) +func (mock *DiscussionClientMock) GetByNumberCalls() []struct { + Repo ghrepo.Interface + Number int +} { + var calls []struct { + Repo ghrepo.Interface + Number int + } + mock.lockGetByNumber.RLock() + calls = mock.calls.GetByNumber + mock.lockGetByNumber.RUnlock() + 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 { + panic("DiscussionClientMock.GetWithCommentsFunc: method is nil but DiscussionClient.GetWithComments was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Number int + CommentLimit int + After string + Newest bool + }{ + Repo: repo, + Number: number, + CommentLimit: commentLimit, + After: after, + Newest: newest, + } + mock.lockGetWithComments.Lock() + mock.calls.GetWithComments = append(mock.calls.GetWithComments, callInfo) + mock.lockGetWithComments.Unlock() + return mock.GetWithCommentsFunc(repo, number, commentLimit, after, newest) +} + +// GetWithCommentsCalls gets all the calls that were made to GetWithComments. +// Check the length with: +// +// len(mockedDiscussionClient.GetWithCommentsCalls()) +func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct { + Repo ghrepo.Interface + Number int + CommentLimit int + After string + Newest bool +} { + var calls []struct { + Repo ghrepo.Interface + Number int + CommentLimit int + After string + Newest bool + } + mock.lockGetWithComments.RLock() + calls = mock.calls.GetWithComments + mock.lockGetWithComments.RUnlock() + return calls +} + +// List calls ListFunc. +func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) { + if mock.ListFunc == nil { + panic("DiscussionClientMock.ListFunc: method is nil but DiscussionClient.List was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Filters ListFilters + After string + Limit int + }{ + Repo: repo, + Filters: filters, + After: after, + Limit: limit, + } + mock.lockList.Lock() + mock.calls.List = append(mock.calls.List, callInfo) + mock.lockList.Unlock() + return mock.ListFunc(repo, filters, after, limit) +} + +// ListCalls gets all the calls that were made to List. +// Check the length with: +// +// len(mockedDiscussionClient.ListCalls()) +func (mock *DiscussionClientMock) ListCalls() []struct { + Repo ghrepo.Interface + Filters ListFilters + After string + Limit int +} { + var calls []struct { + Repo ghrepo.Interface + Filters ListFilters + After string + Limit int + } + mock.lockList.RLock() + calls = mock.calls.List + mock.lockList.RUnlock() + return calls +} + +// ListCategories calls ListCategoriesFunc. +func (mock *DiscussionClientMock) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { + if mock.ListCategoriesFunc == nil { + panic("DiscussionClientMock.ListCategoriesFunc: method is nil but DiscussionClient.ListCategories was just called") + } + callInfo := struct { + Repo ghrepo.Interface + }{ + Repo: repo, + } + mock.lockListCategories.Lock() + mock.calls.ListCategories = append(mock.calls.ListCategories, callInfo) + mock.lockListCategories.Unlock() + return mock.ListCategoriesFunc(repo) +} + +// ListCategoriesCalls gets all the calls that were made to ListCategories. +// Check the length with: +// +// len(mockedDiscussionClient.ListCategoriesCalls()) +func (mock *DiscussionClientMock) ListCategoriesCalls() []struct { + Repo ghrepo.Interface +} { + var calls []struct { + Repo ghrepo.Interface + } + mock.lockListCategories.RLock() + calls = mock.calls.ListCategories + mock.lockListCategories.RUnlock() + return calls +} + +// ListLabels calls ListLabelsFunc. +func (mock *DiscussionClientMock) ListLabels(repo ghrepo.Interface) ([]DiscussionLabel, error) { + if mock.ListLabelsFunc == nil { + panic("DiscussionClientMock.ListLabelsFunc: method is nil but DiscussionClient.ListLabels was just called") + } + callInfo := struct { + Repo ghrepo.Interface + }{ + Repo: repo, + } + mock.lockListLabels.Lock() + mock.calls.ListLabels = append(mock.calls.ListLabels, callInfo) + mock.lockListLabels.Unlock() + return mock.ListLabelsFunc(repo) +} + +// ListLabelsCalls gets all the calls that were made to ListLabels. +// Check the length with: +// +// len(mockedDiscussionClient.ListLabelsCalls()) +func (mock *DiscussionClientMock) ListLabelsCalls() []struct { + Repo ghrepo.Interface +} { + var calls []struct { + Repo ghrepo.Interface + } + mock.lockListLabels.RLock() + calls = mock.calls.ListLabels + mock.lockListLabels.RUnlock() + return calls +} + +// Lock calls LockFunc. +func (mock *DiscussionClientMock) Lock(repo ghrepo.Interface, id string, reason string) error { + if mock.LockFunc == nil { + panic("DiscussionClientMock.LockFunc: method is nil but DiscussionClient.Lock was just called") + } + callInfo := struct { + Repo ghrepo.Interface + ID string + Reason string + }{ + Repo: repo, + ID: id, + Reason: reason, + } + mock.lockLock.Lock() + mock.calls.Lock = append(mock.calls.Lock, callInfo) + mock.lockLock.Unlock() + return mock.LockFunc(repo, id, reason) +} + +// LockCalls gets all the calls that were made to Lock. +// Check the length with: +// +// len(mockedDiscussionClient.LockCalls()) +func (mock *DiscussionClientMock) LockCalls() []struct { + Repo ghrepo.Interface + ID string + Reason string +} { + var calls []struct { + Repo ghrepo.Interface + ID string + Reason string + } + mock.lockLock.RLock() + calls = mock.calls.Lock + mock.lockLock.RUnlock() + return calls +} + +// MarkAnswer calls MarkAnswerFunc. +func (mock *DiscussionClientMock) MarkAnswer(repo ghrepo.Interface, commentID string) error { + if mock.MarkAnswerFunc == nil { + panic("DiscussionClientMock.MarkAnswerFunc: method is nil but DiscussionClient.MarkAnswer was just called") + } + callInfo := struct { + Repo ghrepo.Interface + CommentID string + }{ + Repo: repo, + CommentID: commentID, + } + mock.lockMarkAnswer.Lock() + mock.calls.MarkAnswer = append(mock.calls.MarkAnswer, callInfo) + mock.lockMarkAnswer.Unlock() + return mock.MarkAnswerFunc(repo, commentID) +} + +// MarkAnswerCalls gets all the calls that were made to MarkAnswer. +// Check the length with: +// +// len(mockedDiscussionClient.MarkAnswerCalls()) +func (mock *DiscussionClientMock) MarkAnswerCalls() []struct { + Repo ghrepo.Interface + CommentID string +} { + var calls []struct { + Repo ghrepo.Interface + CommentID string + } + mock.lockMarkAnswer.RLock() + calls = mock.calls.MarkAnswer + mock.lockMarkAnswer.RUnlock() + return calls +} + +// Reopen calls ReopenFunc. +func (mock *DiscussionClientMock) Reopen(repo ghrepo.Interface, id string) (*Discussion, error) { + if mock.ReopenFunc == nil { + panic("DiscussionClientMock.ReopenFunc: method is nil but DiscussionClient.Reopen was just called") + } + callInfo := struct { + Repo ghrepo.Interface + ID string + }{ + Repo: repo, + ID: id, + } + mock.lockReopen.Lock() + mock.calls.Reopen = append(mock.calls.Reopen, callInfo) + mock.lockReopen.Unlock() + return mock.ReopenFunc(repo, id) +} + +// ReopenCalls gets all the calls that were made to Reopen. +// Check the length with: +// +// len(mockedDiscussionClient.ReopenCalls()) +func (mock *DiscussionClientMock) ReopenCalls() []struct { + Repo ghrepo.Interface + ID string +} { + var calls []struct { + Repo ghrepo.Interface + ID string + } + mock.lockReopen.RLock() + calls = mock.calls.Reopen + mock.lockReopen.RUnlock() + return calls +} + +// Search calls SearchFunc. +func (mock *DiscussionClientMock) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) { + if mock.SearchFunc == nil { + panic("DiscussionClientMock.SearchFunc: method is nil but DiscussionClient.Search was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Filters SearchFilters + After string + Limit int + }{ + Repo: repo, + Filters: filters, + After: after, + Limit: limit, + } + mock.lockSearch.Lock() + mock.calls.Search = append(mock.calls.Search, callInfo) + mock.lockSearch.Unlock() + return mock.SearchFunc(repo, filters, after, limit) +} + +// SearchCalls gets all the calls that were made to Search. +// Check the length with: +// +// len(mockedDiscussionClient.SearchCalls()) +func (mock *DiscussionClientMock) SearchCalls() []struct { + Repo ghrepo.Interface + Filters SearchFilters + After string + Limit int +} { + var calls []struct { + Repo ghrepo.Interface + Filters SearchFilters + After string + Limit int + } + mock.lockSearch.RLock() + calls = mock.calls.Search + mock.lockSearch.RUnlock() + return calls +} + +// Unlock calls UnlockFunc. +func (mock *DiscussionClientMock) Unlock(repo ghrepo.Interface, id string) error { + if mock.UnlockFunc == nil { + panic("DiscussionClientMock.UnlockFunc: method is nil but DiscussionClient.Unlock was just called") + } + callInfo := struct { + Repo ghrepo.Interface + ID string + }{ + Repo: repo, + ID: id, + } + mock.lockUnlock.Lock() + mock.calls.Unlock = append(mock.calls.Unlock, callInfo) + mock.lockUnlock.Unlock() + return mock.UnlockFunc(repo, id) +} + +// UnlockCalls gets all the calls that were made to Unlock. +// Check the length with: +// +// len(mockedDiscussionClient.UnlockCalls()) +func (mock *DiscussionClientMock) UnlockCalls() []struct { + Repo ghrepo.Interface + ID string +} { + var calls []struct { + Repo ghrepo.Interface + ID string + } + mock.lockUnlock.RLock() + calls = mock.calls.Unlock + mock.lockUnlock.RUnlock() + return calls +} + +// UnmarkAnswer calls UnmarkAnswerFunc. +func (mock *DiscussionClientMock) UnmarkAnswer(repo ghrepo.Interface, commentID string) error { + if mock.UnmarkAnswerFunc == nil { + panic("DiscussionClientMock.UnmarkAnswerFunc: method is nil but DiscussionClient.UnmarkAnswer was just called") + } + callInfo := struct { + Repo ghrepo.Interface + CommentID string + }{ + Repo: repo, + CommentID: commentID, + } + mock.lockUnmarkAnswer.Lock() + mock.calls.UnmarkAnswer = append(mock.calls.UnmarkAnswer, callInfo) + mock.lockUnmarkAnswer.Unlock() + return mock.UnmarkAnswerFunc(repo, commentID) +} + +// UnmarkAnswerCalls gets all the calls that were made to UnmarkAnswer. +// Check the length with: +// +// len(mockedDiscussionClient.UnmarkAnswerCalls()) +func (mock *DiscussionClientMock) UnmarkAnswerCalls() []struct { + Repo ghrepo.Interface + CommentID string +} { + var calls []struct { + Repo ghrepo.Interface + CommentID string + } + mock.lockUnmarkAnswer.RLock() + calls = mock.calls.UnmarkAnswer + mock.lockUnmarkAnswer.RUnlock() + return calls +} + +// Update calls UpdateFunc. +func (mock *DiscussionClientMock) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { + if mock.UpdateFunc == nil { + panic("DiscussionClientMock.UpdateFunc: method is nil but DiscussionClient.Update was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Input UpdateDiscussionInput + }{ + Repo: repo, + Input: input, + } + mock.lockUpdate.Lock() + mock.calls.Update = append(mock.calls.Update, callInfo) + mock.lockUpdate.Unlock() + return mock.UpdateFunc(repo, input) +} + +// UpdateCalls gets all the calls that were made to Update. +// Check the length with: +// +// len(mockedDiscussionClient.UpdateCalls()) +func (mock *DiscussionClientMock) UpdateCalls() []struct { + Repo ghrepo.Interface + Input UpdateDiscussionInput +} { + var calls []struct { + Repo ghrepo.Interface + Input UpdateDiscussionInput + } + mock.lockUpdate.RLock() + calls = mock.calls.Update + mock.lockUpdate.RUnlock() + return calls +} diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go new file mode 100644 index 00000000000..d1cb14cdd62 --- /dev/null +++ b/pkg/cmd/discussion/client/types.go @@ -0,0 +1,345 @@ +package client + +import "time" + +// Discussion represents a GitHub Discussion as a domain object. +// Fields carry no JSON tags; serialization is handled by ExportData. +type Discussion struct { + ID string + Number int + Title string + Body string + URL string + Closed bool + StateReason string + Author DiscussionActor + Category DiscussionCategory + Labels []DiscussionLabel + Answered bool + AnswerChosenAt time.Time + AnswerChosenBy *DiscussionActor + Comments DiscussionCommentList + ReactionGroups []ReactionGroup + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt time.Time + Locked bool +} + +// ExportData returns a map of the requested fields for JSON output. +// Because domain types carry no JSON struct tags, each field is mapped +// explicitly rather than using reflection. +func (d Discussion) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "id": + data[f] = d.ID + case "number": + data[f] = d.Number + case "title": + data[f] = d.Title + case "body": + data[f] = d.Body + case "url": + data[f] = d.URL + case "closed": + data[f] = d.Closed + case "state": + if d.Closed { + data[f] = "CLOSED" + } else { + data[f] = "OPEN" + } + case "stateReason": + data[f] = d.StateReason + case "author": + data[f] = d.Author.Export() + case "category": + data[f] = d.Category.Export() + case "labels": + labels := make([]interface{}, len(d.Labels)) + for i, l := range d.Labels { + labels[i] = l.Export() + } + data[f] = labels + case "answered": + data[f] = d.Answered + case "answerChosenAt": + if d.AnswerChosenAt.IsZero() { + data[f] = nil + } else { + data[f] = d.AnswerChosenAt + } + case "answerChosenBy": + if d.AnswerChosenBy == nil { + data[f] = nil + } else { + data[f] = d.AnswerChosenBy.Export() + } + case "comments": + comments := make([]interface{}, len(d.Comments.Comments)) + for i, c := range d.Comments.Comments { + comments[i] = c.Export() + } + m := map[string]interface{}{ + "totalCount": d.Comments.TotalCount, + "nodes": comments, + } + if d.Comments.Cursor != "" { + m["cursor"] = d.Comments.Cursor + } + if d.Comments.NextCursor != "" { + m["next"] = d.Comments.NextCursor + } + data[f] = m + case "reactionGroups": + reactions := make([]interface{}, len(d.ReactionGroups)) + for i, rg := range d.ReactionGroups { + reactions[i] = rg.Export() + } + data[f] = reactions + case "createdAt": + data[f] = d.CreatedAt + case "updatedAt": + data[f] = d.UpdatedAt + case "closedAt": + if d.ClosedAt.IsZero() { + data[f] = nil + } else { + data[f] = d.ClosedAt + } + case "locked": + data[f] = d.Locked + } + } + return data +} + +// DiscussionActor represents a GitHub actor (user or bot) associated with a discussion. +type DiscussionActor struct { + ID string + Login string + Name string +} + +// Export returns the author as a map for JSON output. +func (a DiscussionActor) Export() map[string]interface{} { + return map[string]interface{}{ + "id": a.ID, + "login": a.Login, + "name": a.Name, + } +} + +// DiscussionCategory represents a discussion category within a repository. +type DiscussionCategory struct { + ID string + Name string + Slug string + Emoji string + IsAnswerable bool +} + +// Export returns the category as a map for JSON output. +func (c DiscussionCategory) Export() map[string]interface{} { + return map[string]interface{}{ + "id": c.ID, + "name": c.Name, + "slug": c.Slug, + "emoji": c.Emoji, + "isAnswerable": c.IsAnswerable, + } +} + +// DiscussionLabel represents a label applied to a discussion. +type DiscussionLabel struct { + ID string + Name string + Color string +} + +// Export returns the label as a map for JSON output. +func (l DiscussionLabel) Export() map[string]interface{} { + return map[string]interface{}{ + "id": l.ID, + "name": l.Name, + "color": l.Color, + } +} + +// DiscussionComment represents a comment or reply on a discussion. +type DiscussionComment struct { + ID string + URL string + Author DiscussionActor + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []ReactionGroup + Replies DiscussionCommentList +} + +// Export returns the comment as a map for JSON output. +func (c DiscussionComment) Export() map[string]interface{} { + replies := make([]interface{}, len(c.Replies.Comments)) + for i, r := range c.Replies.Comments { + 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() + } + 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, + } +} + +type DiscussionCommentListDirection string + +const ( + DiscussionCommentListDirectionForward DiscussionCommentListDirection = "forward" + DiscussionCommentListDirectionBackward DiscussionCommentListDirection = "backward" +) + +// DiscussionCommentList represents a paginated list of comments on a discussion. +type DiscussionCommentList struct { + Comments []DiscussionComment + TotalCount int + Cursor string + NextCursor string + Direction DiscussionCommentListDirection +} + +// ReactionGroup represents a set of reactions of the same type. +type ReactionGroup struct { + Content string + TotalCount int +} + +// Export returns the reaction group as a map for JSON output. +func (rg ReactionGroup) Export() map[string]interface{} { + return map[string]interface{}{ + "content": rg.Content, + "totalCount": rg.TotalCount, + } +} + +// CloseReason represents the reason for closing a discussion. +type CloseReason string + +const ( + // CloseReasonResolved indicates the discussion topic has been resolved. + CloseReasonResolved CloseReason = "RESOLVED" + // CloseReasonOutdated indicates the discussion is no longer relevant. + CloseReasonOutdated CloseReason = "OUTDATED" + // CloseReasonDuplicate indicates the discussion is a duplicate of another. + CloseReasonDuplicate CloseReason = "DUPLICATE" +) + +// Domain-level filter constants for state. +const ( + FilterStateOpen = "open" + FilterStateClosed = "closed" +) + +// Domain-level constants for order-by field. +const ( + OrderByCreated = "created" + OrderByUpdated = "updated" +) + +// Domain-level constants for order direction. +const ( + OrderDirectionAsc = "asc" + OrderDirectionDesc = "desc" +) + +// DiscussionListResult holds the result of a List or Search call, +// including the discussions, total count, and pagination cursor. +type DiscussionListResult struct { + Discussions []Discussion + TotalCount int + NextCursor string +} + +// ListFilters holds parameters for the repository.discussions query. +// CategoryID must be resolved by the caller before passing to List. +// A nil State indicates no state filtering (all states). +type ListFilters struct { + State *string + CategoryID string + Answered *bool + OrderBy string + Direction string +} + +// SearchFilters holds parameters for the search query used when +// author or label filtering is required. +// A nil State indicates no state filtering (all states). +type SearchFilters struct { + Author string + Labels []string + State *string + Category string + Answered *bool + Keywords string + OrderBy string + Direction string +} + +// CreateDiscussionInput holds the parameters for creating a discussion. +type CreateDiscussionInput struct { + CategoryID string + Title string + Body string + LabelIDs []string +} + +// UpdateDiscussionInput holds optional parameters for updating a discussion. +// Nil pointer fields are left unchanged. +type UpdateDiscussionInput struct { + DiscussionID string + Title *string + Body *string + CategoryID *string + AddLabelIDs []string + RemoveLabelIDs []string +} diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go new file mode 100644 index 00000000000..9c377c7206c --- /dev/null +++ b/pkg/cmd/discussion/create/create.go @@ -0,0 +1,181 @@ +package create + +import ( + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmd/discussion/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// CreateOptions holds the configuration for the discussion create command. +type CreateOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + Client func() (client.DiscussionClient, error) + Prompter prompter.Prompter + + Title string + Body string + Category string + Labels []string +} + +// NewCmdCreate returns a cobra command for creating a GitHub Discussion. +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + Client: shared.DiscussionClientFunc(f), + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new discussion (preview)", + Long: heredoc.Doc(` + Create a new GitHub Discussion in a repository. + + With '--title', '--body', and '--category', a discussion is created non-interactively. + Omitting any of these flags triggers interactive prompts when connected to a terminal. + `), + Example: heredoc.Doc(` + # Create interactively + $ gh discussion create + + # Create non-interactively + $ gh discussion create --title "My question" --category "Q&A" --body "Details here" + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + + if opts.Title != "" && strings.TrimSpace(opts.Title) == "" { + return cmdutil.FlagErrorf("title cannot be blank") + } + if opts.Body != "" && strings.TrimSpace(opts.Body) == "" { + return cmdutil.FlagErrorf("body cannot be blank") + } + if opts.Category != "" && strings.TrimSpace(opts.Category) == "" { + return cmdutil.FlagErrorf("category cannot be blank") + } + + needsInput := opts.Title == "" || opts.Category == "" || opts.Body == "" + if needsInput && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("--title, --body, and --category are required when not running interactively") + } + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the discussion") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body for the discussion") + cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "Category name or slug for the discussion") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Labels to apply to the discussion") + + return cmd +} + +func createRun(opts *CreateOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + c, err := opts.Client() + if err != nil { + return err + } + + opts.IO.StartProgressIndicator() + categories, err := c.ListCategories(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("fetching categories: %w", err) + } + + if opts.Title == "" { + opts.Title, err = opts.Prompter.Input("Discussion title", "") + if err != nil { + return err + } + if strings.TrimSpace(opts.Title) == "" { + return fmt.Errorf("title cannot be blank") + } + } + + var category *client.DiscussionCategory + if opts.Category != "" { + category, err = shared.MatchCategory(opts.Category, categories) + if err != nil { + return err + } + } else { + names := make([]string, len(categories)) + for i, cat := range categories { + names[i] = cat.Name + } + idx, err := opts.Prompter.Select("Discussion category", "", names) + if err != nil { + return err + } + category = &categories[idx] + } + + if opts.Body == "" { + opts.Body, err = opts.Prompter.MarkdownEditor("Discussion body", "", false) + if err != nil { + return err + } + if strings.TrimSpace(opts.Body) == "" { + return fmt.Errorf("body cannot be blank") + } + } + + var labelIDs []string + if len(opts.Labels) > 0 { + opts.IO.StartProgressIndicator() + allLabels, err := c.ListLabels(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + labelIDs, err = shared.ResolveLabels(allLabels, opts.Labels) + if err != nil { + return err + } + } + + input := client.CreateDiscussionInput{ + CategoryID: category.ID, + Title: opts.Title, + Body: opts.Body, + LabelIDs: labelIDs, + } + + opts.IO.StartProgressIndicator() + discussion, err := c.Create(repo, input) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to create discussion: %w", err) + } + + fmt.Fprintln(opts.IO.Out, discussion.URL) + + return nil +} diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go new file mode 100644 index 00000000000..7d72b712ba2 --- /dev/null +++ b/pkg/cmd/discussion/create/create_test.go @@ -0,0 +1,435 @@ +package create + +import ( + "bytes" + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + wantOpts CreateOptions + wantBaseRepo ghrepo.Interface + wantErr string + }{ + { + name: "no flags", + args: "", + isTTY: true, + wantOpts: CreateOptions{}, + }, + { + name: "all flags", + args: "--title 'My question' --body 'Details' --category 'Q&A' --label bug,enhancement", + isTTY: true, + wantOpts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + Labels: []string{"bug", "enhancement"}, + }, + }, + { + name: "extra args", + args: "extra", + isTTY: true, + wantErr: "unknown argument", + }, + { + name: "missing required flags non-interactively", + args: "--title 'My question'", + isTTY: false, + wantErr: "--title, --body, and --category are required when not running interactively", + }, + { + name: "blank title", + args: "--title ' '", + isTTY: true, + wantErr: "title cannot be blank", + }, + { + name: "blank category", + args: "--category ' '", + isTTY: true, + wantErr: "category cannot be blank", + }, + { + name: "blank body", + args: "--body ' '", + isTTY: true, + wantErr: "body cannot be blank", + }, + { + name: "repo override", + args: "--title 'Test' --body 'Body' --category 'Q&A' -R OWNER/REPO", + isTTY: true, + wantBaseRepo: ghrepo.New("OWNER", "REPO"), + wantOpts: CreateOptions{ + Title: "Test", + Body: "Body", + Category: "Q&A", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.isTTY) + ios.SetStdoutTTY(tt.isTTY) + f := &cmdutil.Factory{IOStreams: ios} + var gotOpts *CreateOptions + cmd := NewCmdCreate(f, func(opts *CreateOptions) error { + gotOpts = opts + return nil + }) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Title, gotOpts.Title) + assert.Equal(t, tt.wantOpts.Body, gotOpts.Body) + assert.Equal(t, tt.wantOpts.Category, gotOpts.Category) + assert.Equal(t, tt.wantOpts.Labels, gotOpts.Labels) + + if tt.wantBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) + } + }) + } +} + +func TestCreateRun(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + isTTY bool + setupMock func(*client.DiscussionClientMock) + prompter *prompter.PrompterMock + wantErr string + wantOut string + }{ + { + name: "success non-tty", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "My question", input.Title) + assert.Equal(t, "Details", input.Body) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty with label", + opts: CreateOptions{ + Title: "Feature request", + Body: "Details", + Category: "general", + Labels: []string{"enhancement", "bug"}, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_bug", Name: "bug"}, + {ID: "L_enh", Name: "enhancement"}, + }, nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, []string{"L_enh", "L_bug"}, input.LabelIDs) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "non-tty unknown category", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "nonexistent", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: `unknown category: "nonexistent"`, + }, + { + name: "non-tty list categories query errors", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return nil, fmt.Errorf("network error") + } + }, + wantErr: "fetching categories: network error", + }, + { + name: "non-tty create mutation errors", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + return nil, fmt.Errorf("mutation failed") + } + }, + wantErr: "failed to create discussion: mutation failed", + }, + { + name: "tty prompts for all fields", + isTTY: true, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "My question", input.Title) + assert.Equal(t, "CAT1", input.CategoryID) + assert.Equal(t, "Some body text", input.Body) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "My question", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + assert.Equal(t, []string{"General", "Q&A", "Show and tell"}, options) + return 0, nil + }, + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + assert.False(t, blankAllowed, "body editor should not allow blank input") + return "Some body text", nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty does not prompt when all flags provided", + isTTY: true, + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "My question", input.Title) + assert.Equal(t, "Details", input.Body) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty partial flags prompts only for missing category", + isTTY: true, + opts: CreateOptions{ + Title: "Pre-filled title", + Body: "Pre-filled body", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "Pre-filled title", input.Title) + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "Pre-filled body", input.Body) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty partial flags prompts only for missing body", + isTTY: true, + opts: CreateOptions{ + Title: "Pre-filled title", + Category: "Q&A", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "Pre-filled title", input.Title) + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "Prompted body", input.Body) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return "Prompted body", nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty partial flags prompts only for missing title", + isTTY: true, + opts: CreateOptions{ + Body: "Pre-filled body", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "Prompted title", input.Title) + assert.Equal(t, "CAT1", input.CategoryID) + assert.Equal(t, "Pre-filled body", input.Body) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "Prompted title", nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty blank title returns error", + isTTY: true, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + prompter: &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return " ", nil + }, + }, + wantErr: "title cannot be blank", + }, + { + name: "tty blank body returns error", + isTTY: true, + opts: CreateOptions{ + Title: "Valid title", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return " ", nil + }, + }, + wantErr: "body cannot be blank", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + + mockClient := &client.DiscussionClientMock{} + tt.setupMock(mockClient) + + opts := tt.opts + opts.IO = ios + opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + opts.Client = func() (client.DiscussionClient, error) { + return mockClient, nil + } + if tt.prompter != nil { + opts.Prompter = tt.prompter + } + + err := createRun(&opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + }) + } +} + +func sampleCategories() []client.DiscussionCategory { + return []client.DiscussionCategory{ + {ID: "CAT1", Name: "General", Slug: "general"}, + {ID: "CAT2", Name: "Q&A", Slug: "q-a"}, + {ID: "CAT3", Name: "Show and tell", Slug: "show-and-tell"}, + } +} + +func sampleDiscussion() *client.Discussion { + return &client.Discussion{ + Number: 5, + Title: "My question", + URL: "https://github.com/OWNER/REPO/discussions/5", + } +} diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go new file mode 100644 index 00000000000..2ebc60a31cb --- /dev/null +++ b/pkg/cmd/discussion/discussion.go @@ -0,0 +1,49 @@ +package discussion + +import ( + "github.com/MakeNowJust/heredoc" + cmdCreate "github.com/cli/cli/v2/pkg/cmd/discussion/create" + cmdEdit "github.com/cli/cli/v2/pkg/cmd/discussion/edit" + cmdList "github.com/cli/cli/v2/pkg/cmd/discussion/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/discussion/view" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdDiscussion returns the top-level "discussion" command. +func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "discussion ", + Short: "Work with GitHub Discussions (preview)", + Long: heredoc.Doc(` + Working with discussions in the GitHub CLI is in preview and subject to change without notice. + `), + Example: heredoc.Doc(` + $ gh discussion list + $ gh discussion create --category "General" --title "Hello" + $ gh discussion view 123 + `), + Annotations: map[string]string{ + "help:arguments": heredoc.Doc(` + A discussion can be supplied as argument in any of the following formats: + - by number, e.g. "123"; or + - by URL, e.g. "https://github.com/OWNER/REPO/discussions/123". + `), + }, + GroupID: "core", + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmdutil.AddGroup(cmd, "General commands", + cmdCreate.NewCmdCreate(f, nil), + cmdList.NewCmdList(f, nil), + ) + + cmdutil.AddGroup(cmd, "Targeted commands", + cmdEdit.NewCmdEdit(f, nil), + cmdView.NewCmdView(f, nil), + ) + + return cmd +} diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go new file mode 100644 index 00000000000..b63774c5226 --- /dev/null +++ b/pkg/cmd/discussion/edit/edit.go @@ -0,0 +1,272 @@ +package edit + +import ( + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmd/discussion/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// EditOptions holds the configuration for the discussion edit command. +type EditOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + Client func() (client.DiscussionClient, error) + Prompter prompter.Prompter + + Interactive bool + TitleProvided bool + BodyProvided bool + CategoryProvided bool + LabelsProvided bool + + DiscussionNumber int + Title string + Body string + BodyFile string + Category string + AddLabels []string + RemoveLabels []string +} + +// NewCmdEdit returns a cobra command for editing a GitHub Discussion. +func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { + opts := &EditOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + Client: shared.DiscussionClientFunc(f), + } + + cmd := &cobra.Command{ + Use: "edit { | }", + Short: "Edit a discussion (preview)", + Long: heredoc.Doc(` + Edit a GitHub Discussion. + + Without flags, the command runs interactively when connected to a terminal. + Use flags to update specific fields non-interactively. + `), + Example: heredoc.Doc(` + # Edit interactively + $ gh discussion edit 123 + + # Update title, body, and category + $ gh discussion edit 123 --title "Updated title" --body "Updated body" --category "Ideas" + + # Update body from a file + $ gh discussion edit 123 --body-file body.md + + # Add and remove labels + $ gh discussion edit 123 --add-label "bug,help wanted" --remove-label "stale" + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, repo, err := shared.ParseDiscussionArg(args[0]) + if err != nil { + return cmdutil.FlagErrorWrap(err) + } + + if repo != nil { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return repo, nil + } + } else { + opts.BaseRepo = f.BaseRepo + } + + opts.DiscussionNumber = number + + if err := cmdutil.MutuallyExclusive("specify only one of --body or --body-file", + cmd.Flags().Changed("body"), cmd.Flags().Changed("body-file")); err != nil { + return err + } + + opts.TitleProvided = cmd.Flags().Changed("title") + opts.BodyProvided = cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") + opts.CategoryProvided = cmd.Flags().Changed("category") + opts.LabelsProvided = len(opts.AddLabels) > 0 || len(opts.RemoveLabels) > 0 + + noFlagsSet := !opts.TitleProvided && !opts.BodyProvided && !opts.CategoryProvided && !opts.LabelsProvided + if noFlagsSet && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("specify at least one flag to update the discussion non-interactively") + } + + opts.Interactive = noFlagsSet + + if runF != nil { + return runF(opts) + } + return editRun(opts) + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "New title for the discussion") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "New body for the discussion") + cmd.Flags().StringVarP(&opts.BodyFile, "body-file", "F", "", "Read body text from file (use \"-\" to read from standard input)") + cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "New category name or slug for the discussion") + cmd.Flags().StringSliceVar(&opts.AddLabels, "add-label", nil, "Add labels by `name`") + cmd.Flags().StringSliceVar(&opts.RemoveLabels, "remove-label", nil, "Remove labels by `name`") + + return cmd +} + +func editRun(opts *EditOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + c, err := opts.Client() + if err != nil { + return err + } + + opts.IO.StartProgressIndicator() + discussion, err := c.GetByNumber(repo, opts.DiscussionNumber) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + input := client.UpdateDiscussionInput{ + DiscussionID: discussion.ID, + } + + if opts.Interactive { + changed, err := promptEdit(opts, discussion, c, repo, &input) + if err != nil { + return err + } + + if !changed { + return fmt.Errorf("no changes made") + } + } else { + if opts.TitleProvided { + if strings.TrimSpace(opts.Title) == "" { + return cmdutil.FlagErrorf("title cannot be blank") + } + input.Title = &opts.Title + } + if opts.BodyProvided { + if opts.BodyFile != "" { + bodyBytes, err := cmdutil.ReadFile(opts.BodyFile, opts.IO.In) + if err != nil { + return err + } + opts.Body = string(bodyBytes) + } + input.Body = &opts.Body + } + if opts.CategoryProvided { + opts.IO.StartProgressIndicator() + categories, err := c.ListCategories(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("fetching categories: %w", err) + } + cat, err := shared.MatchCategory(opts.Category, categories) + if err != nil { + return err + } + input.CategoryID = &cat.ID + } + + if opts.LabelsProvided { + opts.IO.StartProgressIndicator() + allLabels, err := c.ListLabels(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("fetching labels: %w", err) + } + if len(opts.AddLabels) > 0 { + input.AddLabelIDs, err = shared.ResolveLabels(allLabels, opts.AddLabels) + if err != nil { + return err + } + } + if len(opts.RemoveLabels) > 0 { + input.RemoveLabelIDs, err = shared.ResolveLabels(allLabels, opts.RemoveLabels) + if err != nil { + return err + } + } + } + } + + opts.IO.StartProgressIndicator() + updated, err := c.Update(repo, input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, updated.URL) + return nil +} + +// promptEdit runs the interactive flow, populating input with user choices. It returns a boolean indicating whether any +// changes were made, and an error if the process failed. +func promptEdit(opts *EditOptions, discussion *client.Discussion, c client.DiscussionClient, repo ghrepo.Interface, input *client.UpdateDiscussionInput) (bool, error) { + choices := []string{"Title", "Body", "Category"} + selected, err := opts.Prompter.MultiSelect("What would you like to edit?", nil, choices) + if err != nil { + return false, err + } + if len(selected) == 0 { + return false, nil + } + + for _, idx := range selected { + switch choices[idx] { + case "Title": + title, err := opts.Prompter.Input("Title", discussion.Title) + if err != nil { + return false, err + } + if strings.TrimSpace(title) == "" { + return false, fmt.Errorf("title cannot be blank") + } + input.Title = &title + + case "Body": + body, err := opts.Prompter.MarkdownEditor("Body", discussion.Body, false) + if err != nil { + return false, err + } + input.Body = &body + + case "Category": + opts.IO.StartProgressIndicator() + categories, err := c.ListCategories(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return false, err + } + names := make([]string, len(categories)) + for i, cat := range categories { + names[i] = cat.Name + } + currentName := discussion.Category.Name + idx, err := opts.Prompter.Select("Category", currentName, names) + if err != nil { + return false, err + } + input.CategoryID = &categories[idx].ID + } + } + + return true, nil +} diff --git a/pkg/cmd/discussion/edit/edit_test.go b/pkg/cmd/discussion/edit/edit_test.go new file mode 100644 index 00000000000..5baefc09d02 --- /dev/null +++ b/pkg/cmd/discussion/edit/edit_test.go @@ -0,0 +1,659 @@ +package edit + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + wantOpts EditOptions + wantBaseRepo ghrepo.Interface + wantErr string + }{ + { + name: "all flags", + args: "123 --title 'New title' --body 'New body' --category 'Ideas'", + isTTY: true, + wantOpts: EditOptions{ + DiscussionNumber: 123, + TitleProvided: true, + Title: "New title", + BodyProvided: true, + Body: "New body", + CategoryProvided: true, + Category: "Ideas", + }, + }, + { + name: "url arg overrides base repo", + args: "https://github.com/OWNER2/REPO2/discussions/42", + isTTY: true, + wantOpts: EditOptions{ + DiscussionNumber: 42, + Interactive: true, + }, + wantBaseRepo: ghrepo.New("OWNER2", "REPO2"), + }, + { + name: "interactive mode when no flags and tty", + args: "123", + isTTY: true, + wantOpts: EditOptions{ + DiscussionNumber: 123, + Interactive: true, + }, + }, + { + name: "labels flags", + args: "123 --add-label 'bug,help wanted' --remove-label stale", + isTTY: true, + wantOpts: EditOptions{ + DiscussionNumber: 123, + AddLabels: []string{"bug", "help wanted"}, + RemoveLabels: []string{"stale"}, + LabelsProvided: true, + }, + }, + { + name: "mutual exclusion --body and --body-file", + args: "123 --body 'inline' --body-file body.md", + isTTY: true, + wantErr: "specify only one of --body or --body-file", + }, + { + name: "no flags no TTY", + args: "123", + isTTY: false, + wantErr: "specify at least one flag to update the discussion non-interactively", + }, + { + name: "no args", + args: "", + isTTY: true, + wantErr: "accepts 1 arg(s)", + }, + { + name: "extra args", + args: "123 extra", + isTTY: true, + wantErr: "accepts 1 arg(s)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.isTTY) + ios.SetStdoutTTY(tt.isTTY) + f := &cmdutil.Factory{IOStreams: ios} + var gotOpts *EditOptions + cmd := NewCmdEdit(f, func(opts *EditOptions) error { + gotOpts = opts + return nil + }) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.DiscussionNumber, gotOpts.DiscussionNumber) + assert.Equal(t, tt.wantOpts.Interactive, gotOpts.Interactive) + assert.Equal(t, tt.wantOpts.TitleProvided, gotOpts.TitleProvided) + assert.Equal(t, tt.wantOpts.BodyProvided, gotOpts.BodyProvided) + assert.Equal(t, tt.wantOpts.CategoryProvided, gotOpts.CategoryProvided) + assert.Equal(t, tt.wantOpts.LabelsProvided, gotOpts.LabelsProvided) + assert.Equal(t, tt.wantOpts.Title, gotOpts.Title) + assert.Equal(t, tt.wantOpts.Body, gotOpts.Body) + assert.Equal(t, tt.wantOpts.Category, gotOpts.Category) + assert.Equal(t, tt.wantOpts.AddLabels, gotOpts.AddLabels) + assert.Equal(t, tt.wantOpts.RemoveLabels, gotOpts.RemoveLabels) + + if tt.wantBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) + } + }) + } +} + +func TestEditRun(t *testing.T) { + tests := []struct { + name string + opts EditOptions + bodyFileContent string // if non-empty, creates a temp file and sets opts.BodyFile + stdinContent string // if non-empty, writes to stdin buffer + isTTY bool + setupMock func(*client.DiscussionClientMock) + prompter *prompter.PrompterMock + wantErr string + wantOut string + }{ + { + name: "success non-tty title only", + opts: EditOptions{ + Title: "Updated title", + TitleProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "D_1", input.DiscussionID) + require.NotNil(t, input.Title) + assert.Equal(t, "Updated title", *input.Title) + assert.Nil(t, input.Body) + assert.Nil(t, input.CategoryID) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty body only", + opts: EditOptions{ + Body: "Updated body", + BodyProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + require.NotNil(t, input.Body) + assert.Equal(t, "Updated body", *input.Body) + assert.Nil(t, input.CategoryID) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty category change", + opts: EditOptions{ + Category: "Q&A", + CategoryProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + assert.Nil(t, input.Body) + require.NotNil(t, input.CategoryID) + assert.Equal(t, "CAT2", *input.CategoryID) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty add/remove labels only", + opts: EditOptions{ + AddLabels: []string{"bug", "enhancement"}, + RemoveLabels: []string{"stale"}, + LabelsProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_bug", Name: "bug"}, + {ID: "L_enh", Name: "enhancement"}, + {ID: "L_stale", Name: "stale"}, + }, nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + assert.Nil(t, input.Body) + assert.Nil(t, input.CategoryID) + assert.Equal(t, []string{"L_bug", "L_enh"}, input.AddLabelIDs) + assert.Equal(t, []string{"L_stale"}, input.RemoveLabelIDs) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty add labels only", + opts: EditOptions{ + AddLabels: []string{"bug", "enhancement"}, + LabelsProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_bug", Name: "bug"}, + {ID: "L_enh", Name: "enhancement"}, + }, nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, []string{"L_bug", "L_enh"}, input.AddLabelIDs) + assert.Nil(t, input.RemoveLabelIDs) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty remove labels only", + opts: EditOptions{ + RemoveLabels: []string{"stale"}, + LabelsProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_stale", Name: "stale"}, + }, nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.AddLabelIDs) + assert.Equal(t, []string{"L_stale"}, input.RemoveLabelIDs) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "success non-tty all flags", + opts: EditOptions{ + Title: "New title", + Body: "New body", + Category: "General", + AddLabels: []string{"bug"}, + RemoveLabels: []string{"stale"}, + TitleProvided: true, + BodyProvided: true, + CategoryProvided: true, + LabelsProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_bug", Name: "bug"}, + {ID: "L_stale", Name: "stale"}, + }, nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + require.NotNil(t, input.Title) + assert.Equal(t, "New title", *input.Title) + require.NotNil(t, input.Body) + assert.Equal(t, "New body", *input.Body) + require.NotNil(t, input.CategoryID) + assert.Equal(t, "CAT1", *input.CategoryID) + assert.Equal(t, []string{"L_bug"}, input.AddLabelIDs) + assert.Equal(t, []string{"L_stale"}, input.RemoveLabelIDs) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "non-tty blank title returns error", + opts: EditOptions{ + Title: " ", + TitleProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + }, + wantErr: "title cannot be blank", + }, + { + name: "non-tty unknown category", + opts: EditOptions{ + Category: "nonexistent", + CategoryProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: `unknown category: "nonexistent"`, + }, + { + name: "non-tty list categories error", + opts: EditOptions{ + Category: "General", + CategoryProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return nil, fmt.Errorf("network error") + } + }, + wantErr: "network error", + }, + { + name: "non-tty unresolvable label returns error", + opts: EditOptions{ + AddLabels: []string{"bug", "nonexistent", "also-missing"}, + LabelsProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListLabelsFunc = func(repo ghrepo.Interface) ([]client.DiscussionLabel, error) { + return []client.DiscussionLabel{ + {ID: "L_bug", Name: "bug"}, + }, nil + } + }, + wantErr: "labels not found: nonexistent, also-missing", + }, + { + name: "GetByNumber error", + opts: EditOptions{ + Title: "whatever", + TitleProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return nil, fmt.Errorf("not found") + } + }, + wantErr: "not found", + }, + { + name: "Update error", + opts: EditOptions{ + Title: "Updated title", + TitleProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + return nil, fmt.Errorf("mutation failed") + } + }, + wantErr: "mutation failed", + }, + { + name: "tty interactive select title", + isTTY: true, + opts: EditOptions{Interactive: true}, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + require.NotNil(t, input.Title) + assert.Equal(t, "New title", *input.Title) + assert.Nil(t, input.Body) + assert.Nil(t, input.CategoryID) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + assert.Equal(t, []string{"Title", "Body", "Category"}, options) + return []int{0}, nil + }, + InputFunc: func(prompt, defaultValue string) (string, error) { + assert.Equal(t, "Original title", defaultValue) + return "New title", nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty interactive select body", + isTTY: true, + opts: EditOptions{Interactive: true}, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + require.NotNil(t, input.Body) + assert.Equal(t, "New body text", *input.Body) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{1}, nil // body is index 1 + }, + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + assert.Equal(t, "Original body", defaultValue) + return "New body text", nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty interactive select category", + isTTY: true, + opts: EditOptions{Interactive: true}, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + assert.Nil(t, input.Body) + require.NotNil(t, input.CategoryID) + assert.Equal(t, "CAT2", *input.CategoryID) + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{2}, nil // category is index 2 + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + assert.Equal(t, "General", defaultValue) + assert.Equal(t, []string{"General", "Q&A", "Show and tell"}, options) + return 1, nil // select "Q&A" + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty interactive nothing selected is a no-op", + isTTY: true, + opts: EditOptions{Interactive: true}, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{}, nil + }, + }, + wantErr: "no changes made", + }, + { + name: "success non-tty body-file", + bodyFileContent: "Body from file", + opts: EditOptions{ + BodyProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + require.NotNil(t, input.Body) + assert.Equal(t, "Body from file", *input.Body) + assert.Nil(t, input.CategoryID) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty interactive blank title returns error", + isTTY: true, + opts: EditOptions{Interactive: true}, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + }, + prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil + }, + InputFunc: func(prompt, defaultValue string) (string, error) { + return " ", nil + }, + }, + wantErr: "title cannot be blank", + }, + { + name: "success non-tty body-file from stdin", + stdinContent: "Body from stdin", + opts: EditOptions{ + BodyFile: "-", + BodyProvided: true, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return sampleDiscussion(), nil + } + m.UpdateFunc = func(repo ghrepo.Interface, input client.UpdateDiscussionInput) (*client.Discussion, error) { + assert.Nil(t, input.Title) + require.NotNil(t, input.Body) + assert.Equal(t, "Body from stdin", *input.Body) + assert.Nil(t, input.CategoryID) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, stdin, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + + if tt.stdinContent != "" { + stdin.WriteString(tt.stdinContent) + } + + mockClient := &client.DiscussionClientMock{} + if tt.setupMock != nil { + tt.setupMock(mockClient) + } + + opts := tt.opts + if tt.bodyFileContent != "" { + dir := t.TempDir() + f := filepath.Join(dir, "body.md") + require.NoError(t, os.WriteFile(f, []byte(tt.bodyFileContent), 0600)) + opts.BodyFile = f + } + opts.IO = ios + opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + opts.Client = func() (client.DiscussionClient, error) { + return mockClient, nil + } + if tt.prompter != nil { + opts.Prompter = tt.prompter + } + + err := editRun(&opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + }) + } +} + +func sampleCategories() []client.DiscussionCategory { + return []client.DiscussionCategory{ + {ID: "CAT1", Name: "General", Slug: "general"}, + {ID: "CAT2", Name: "Q&A", Slug: "q-a"}, + {ID: "CAT3", Name: "Show and tell", Slug: "show-and-tell"}, + } +} + +func sampleDiscussion() *client.Discussion { + return &client.Discussion{ + ID: "D_1", + Number: 5, + Title: "Original title", + Body: "Original body", + URL: "https://github.com/OWNER/REPO/discussions/5", + Category: client.DiscussionCategory{ + ID: "CAT1", + Name: "General", + Slug: "general", + }, + } +} diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go new file mode 100644 index 00000000000..3de5df6c6b6 --- /dev/null +++ b/pkg/cmd/discussion/list/list.go @@ -0,0 +1,354 @@ +package list + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmd/discussion/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const defaultLimit = 30 + +// discussionListFields lists the field names available for --json output +// on the discussion list command. This excludes fields like "comments" +// that are only populated by the view command. +var discussionListFields = []string{ + "id", + "number", + "title", + "body", + "url", + "closed", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} + +// ListOptions holds the configuration for the discussion list command. +type ListOptions struct { + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + Client func() (client.DiscussionClient, error) + + Author string + Category string + Labels []string + State string + Limit int + Answered *bool + Sort string + Order string + Search string + After string + + WebMode bool + Exporter cmdutil.Exporter + Now func() time.Time +} + +// NewCmdList creates the "discussion list" command. +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Browser: f.Browser, + Now: time.Now, + } + + cmd := &cobra.Command{ + Use: "list [flags]", + Short: "List discussions in a repository (preview)", + Long: heredoc.Doc(` + List discussions in a GitHub repository. By default, only open discussions + are shown. + `), + Example: heredoc.Doc(` + # List open discussions + $ gh discussion list + + # List discussions with a specific category + $ gh discussion list --category General + + # List closed discussions by author + $ gh discussion list --state closed --author monalisa + + # List all discussions (closed or open) by label + $ gh discussion list --state all --label bug,enhancement + + # List answered discussions as JSON + $ gh discussion list --answered --json number,title,url + + # List unanswered discussions as JSON + $ gh discussion list --answered=false --json number,title,url + `), + Aliases: []string{"ls"}, + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + opts.Client = shared.DiscussionClientFunc(f) + + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") + cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "Filter by category name or slug") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by label") + cmdutil.StringEnumFlag(cmd, &opts.State, "state", "s", "open", []string{"open", "closed", "all"}, "Filter by state") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of discussions to fetch (default %d)", defaultLimit)) + cmdutil.NilBoolFlag(cmd, &opts.Answered, "answered", "", "Filter by answered state") + cmdutil.StringEnumFlag(cmd, &opts.Sort, "sort", "", "updated", []string{"created", "updated"}, "Sort by field") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "desc", []string{"asc", "desc"}, "Order of results") + cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search discussions with `query`") + cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of results") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List discussions in the web browser") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionListFields) + + return cmd +} + +// toFilterState maps CLI state strings to domain-level filter state pointers. +// "all" maps to nil (no state filter). +func toFilterState(v string) *string { + switch v { + case "open": + s := client.FilterStateOpen + return &s + case "closed": + s := client.FilterStateClosed + return &s + default: + return nil + } +} + +func listRun(opts *ListOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.WebMode { + return openInBrowser(opts, repo) + } + + dc, err := opts.Client() + if err != nil { + return err + } + + var categoryID string + var categorySlug string + if opts.Category != "" { + categories, err := dc.ListCategories(repo) + if err != nil { + return err + } + cat, err := shared.MatchCategory(opts.Category, categories) + if err != nil { + return err + } + categoryID = cat.ID + categorySlug = cat.Slug + } + + state := toFilterState(opts.State) + + var result *client.DiscussionListResult + + useSearch := opts.Author != "" || len(opts.Labels) > 0 || opts.Search != "" + if useSearch { + filters := client.SearchFilters{ + Author: opts.Author, + Labels: opts.Labels, + State: state, + Category: categorySlug, + Answered: opts.Answered, + Keywords: opts.Search, + OrderBy: opts.Sort, + Direction: opts.Order, + } + result, err = dc.Search(repo, filters, opts.After, opts.Limit) + } else { + filters := client.ListFilters{ + State: state, + CategoryID: categoryID, + Answered: opts.Answered, + OrderBy: opts.Sort, + Direction: opts.Order, + } + result, err = dc.List(repo, filters, opts.After, opts.Limit) + } + if err != nil { + return err + } + + if opts.Exporter != nil { + envelope := map[string]interface{}{ + "totalCount": result.TotalCount, + "discussions": result.Discussions, + "next": result.NextCursor, + } + return opts.Exporter.Write(opts.IO, envelope) + } + + if len(result.Discussions) == 0 { + return noResults(repo, opts.State) + } + + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) + } + + printDiscussions(opts, ghrepo.FullName(repo), result.Discussions, result.TotalCount) + return nil +} + +func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error { + discussionsURL := ghrepo.GenerateRepoURL(repo, "discussions") + + var queryParts []string + if opts.Search != "" { + queryParts = append(queryParts, opts.Search) + } + if opts.State != "" && opts.State != "all" { + queryParts = append(queryParts, "is:"+opts.State) + } + if opts.Author != "" { + queryParts = append(queryParts, fmt.Sprintf("author:%q", opts.Author)) + } + for _, l := range opts.Labels { + queryParts = append(queryParts, fmt.Sprintf("label:%q", l)) + } + if opts.Category != "" { + queryParts = append(queryParts, fmt.Sprintf("category:%q", opts.Category)) + } + if opts.Answered != nil { + if *opts.Answered { + queryParts = append(queryParts, "is:answered") + } else { + queryParts = append(queryParts, "is:unanswered") + } + } + + if len(queryParts) > 0 { + discussionsURL += "?" + url.Values{"q": {strings.Join(queryParts, " ")}}.Encode() + } + + if opts.IO.IsStderrTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(discussionsURL)) + } + return opts.Browser.Browse(discussionsURL) +} + +func noResults(repo ghrepo.Interface, state string) error { + switch state { + case "open": + return cmdutil.NewNoResultsError(fmt.Sprintf("no open discussions match your search in %s", ghrepo.FullName(repo))) + case "closed": + return cmdutil.NewNoResultsError(fmt.Sprintf("no closed discussions match your search in %s", ghrepo.FullName(repo))) + default: + return cmdutil.NewNoResultsError(fmt.Sprintf("no discussions match your search in %s", ghrepo.FullName(repo))) + } +} + +func listHeader(repoName string, count, total int, state string) string { + switch state { + case "open": + return fmt.Sprintf("Showing %d of %d open discussions in %s", count, total, repoName) + case "closed": + return fmt.Sprintf("Showing %d of %d closed discussions in %s", count, total, repoName) + default: + return fmt.Sprintf("Showing %d of %d discussions in %s", count, total, repoName) + } +} + +func printDiscussions(opts *ListOptions, repoName string, discussions []client.Discussion, totalCount int) { + isTerminal := opts.IO.IsStdoutTTY() + cs := opts.IO.ColorScheme() + now := opts.Now() + + if isTerminal { + title := listHeader(repoName, len(discussions), totalCount, opts.State) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) + } + + headers := []string{"ID", "TITLE", "CATEGORY", "LABELS", "ANSWERED", "UPDATED"} + if !isTerminal { + headers = []string{"ID", "STATE", "TITLE", "CATEGORY", "LABELS", "ANSWERED", "UPDATED"} + } + tp := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...)) + + for _, d := range discussions { + if isTerminal { + idColor := cs.Green + if d.Closed { + idColor = cs.Muted + } + tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor)) + } else { + tp.AddField(fmt.Sprintf("%d", d.Number)) + if d.Closed { + tp.AddField("CLOSED") + } else { + tp.AddField("OPEN") + } + } + + tp.AddField(text.RemoveExcessiveWhitespace(d.Title)) + tp.AddField(d.Category.Name) + + labelNames := make([]string, len(d.Labels)) + for i, l := range d.Labels { + if isTerminal { + labelNames[i] = cs.Label(l.Color, l.Name) + } else { + labelNames[i] = l.Name + } + } + tp.AddField(strings.Join(labelNames, ", "), tableprinter.WithTruncate(nil)) + + if d.Answered { + tp.AddField("✓") + } else { + tp.AddField("") + } + + tp.AddTimeField(now, d.UpdatedAt, cs.Muted) + tp.EndRow() + } + + _ = tp.Render() + + if remaining := totalCount - len(discussions); isTerminal && remaining > 0 { + fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining) + } +} diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go new file mode 100644 index 00000000000..cb43d561c8d --- /dev/null +++ b/pkg/cmd/discussion/list/list_test.go @@ -0,0 +1,611 @@ +package list + +import ( + "bytes" + "testing" + "time" + + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fixedTime() time.Time { + return time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) +} + +func sampleDiscussions() []client.Discussion { + return []client.Discussion{ + { + Number: 42, + Title: "Bug report discussion", + URL: "https://github.com/OWNER/REPO/discussions/42", + Author: client.DiscussionActor{Login: "monalisa"}, + Category: client.DiscussionCategory{ + ID: "CAT1", + Name: "General", + Slug: "general", + }, + Labels: []client.DiscussionLabel{ + {ID: "L1", Name: "bug", Color: "d73a4a"}, + }, + Answered: true, + UpdatedAt: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), + }, + { + Number: 41, + Title: "Feature request", + URL: "https://github.com/OWNER/REPO/discussions/41", + Author: client.DiscussionActor{Login: "octocat"}, + Category: client.DiscussionCategory{ + ID: "CAT2", + Name: "Ideas", + Slug: "ideas", + }, + Labels: []client.DiscussionLabel{}, + Answered: false, + UpdatedAt: time.Date(2025, 2, 20, 12, 0, 0, 0, time.UTC), + }, + } +} + +func sampleResult() *client.DiscussionListResult { + return &client.DiscussionListResult{ + Discussions: sampleDiscussions(), + TotalCount: 2, + } +} + +func sampleCategories() []client.DiscussionCategory { + return []client.DiscussionCategory{ + {ID: "CAT1", Name: "General", Slug: "general", IsAnswerable: true}, + {ID: "CAT2", Name: "Ideas", Slug: "ideas", IsAnswerable: false}, + {ID: "CAT3", Name: "Show and tell", Slug: "show-and-tell", IsAnswerable: false}, + } +} + +func TestListRun_tty(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return sampleResult(), nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + out := stdout.String() + assert.Contains(t, out, "Showing 2 of 2 open discussions in OWNER/REPO") + assert.Contains(t, out, "#42") + assert.Contains(t, out, "Bug report discussion") + assert.Contains(t, out, "General") + assert.Contains(t, out, "✓") + assert.Contains(t, out, "#41") + assert.Contains(t, out, "Feature request") +} + +func TestListRun_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return sampleResult(), nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.NotContains(t, out, "Showing") + assert.Contains(t, out, "42") + assert.Contains(t, out, "OPEN") + assert.Contains(t, out, "Bug report discussion") +} + +func TestListRun_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{ + Discussions: sampleDiscussions(), + TotalCount: 2, + NextCursor: "CURSOR123", + }, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"number", "title"}) + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Exporter: exporter, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"discussions"`) + assert.Contains(t, out, `"next"`) +} + +func TestListRun_web(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStderrTTY(true) + + br := &browser.Stub{} + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Browser: br, + WebMode: true, + State: "open", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + assert.Contains(t, stderr.String(), "Opening") + assert.Contains(t, br.BrowsedURL(), "github.com/OWNER/REPO/discussions") +} + +func TestListRun_noResults(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{}, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.Error(t, err) + var noResultsErr cmdutil.NoResultsError + assert.ErrorAs(t, err, &noResultsErr) +} + +func TestListRun_categoryFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + assert.Equal(t, "CAT1", filters.CategoryID) + return &client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Category: "general", + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_categoryNotFound(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Category: "nonexistent", + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown category: "nonexistent"`) +} + +func TestListRun_authorFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (*client.DiscussionListResult, error) { + assert.Equal(t, "monalisa", filters.Author) + return &client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Author: "monalisa", + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_labelFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (*client.DiscussionListResult, error) { + assert.Equal(t, []string{"bug", "docs"}, filters.Labels) + return &client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Labels: []string{"bug", "docs"}, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_searchFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (*client.DiscussionListResult, error) { + assert.Equal(t, "some keywords", filters.Keywords) + return &client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Search: "some keywords", + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_afterCursor(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + assert.Equal(t, "CURSOR_ABC", after) + return sampleResult(), nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Sort: "updated", + Order: "desc", + After: "CURSOR_ABC", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) +} + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + args string + wantsErr bool + }{ + { + name: "no flags", + args: "", + }, + { + name: "state flag", + args: "--state closed", + }, + { + name: "label flag", + args: "--label bug --label docs", + }, + { + name: "author flag", + args: "--author monalisa", + }, + { + name: "category flag", + args: "--category general", + }, + { + name: "limit flag", + args: "--limit 10", + }, + { + name: "invalid limit", + args: "--limit 0", + wantsErr: true, + }, + { + name: "web flag", + args: "--web", + }, + { + name: "sort flag", + args: "--sort created", + }, + { + name: "order flag", + args: "--order asc", + }, + { + name: "sort and order flags", + args: "--sort created --order asc", + }, + { + name: "search flag", + args: "--search \"some query\"", + }, + { + name: "after flag", + args: "--after CURSOR123", + }, + { + name: "invalid state", + args: "--state invalid", + wantsErr: true, + }, + { + name: "invalid sort", + args: "--sort invalid", + wantsErr: true, + }, + { + name: "invalid order", + args: "--order invalid", + wantsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Browser: &browser.Stub{}, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + } + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(o *ListOptions) error { + gotOpts = o + return nil + }) + + argv := []string{} + if tt.args != "" { + argv = splitArgs(tt.args) + } + cmd.SetArgs(argv) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + _ = gotOpts + }) + } +} + +func TestToFilterState(t *testing.T) { + tests := []struct { + input string + want *string + }{ + {"open", strPtr(client.FilterStateOpen)}, + {"closed", strPtr(client.FilterStateClosed)}, + {"all", nil}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toFilterState(tt.input) + if tt.want == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + } + }) + } +} + +func strPtr(s string) *string { return &s } + +func splitArgs(s string) []string { + var args []string + for _, part := range splitRespectingQuotes(s) { + if part != "" { + args = append(args, part) + } + } + return args +} + +func splitRespectingQuotes(s string) []string { + var result []string + var current []byte + inQuote := false + for i := 0; i < len(s); i++ { + if s[i] == '"' { + inQuote = !inQuote + continue + } + if s[i] == ' ' && !inQuote { + result = append(result, string(current)) + current = nil + continue + } + current = append(current, s[i]) + } + result = append(result, string(current)) + return result +} + +func TestListRun_closedState(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + closed := []client.Discussion{ + { + Number: 10, + Title: "Old discussion", + Closed: true, + Category: client.DiscussionCategory{Name: "General"}, + Labels: []client.DiscussionLabel{}, + UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{ + Discussions: closed, + TotalCount: 1, + }, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "closed", + Limit: 30, + Sort: "updated", + Order: "desc", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "closed discussions") + assert.Contains(t, out, "Old discussion") + assert.Contains(t, out, "#10") +} diff --git a/pkg/cmd/discussion/shared/categories.go b/pkg/cmd/discussion/shared/categories.go new file mode 100644 index 00000000000..5e0ca33cdc1 --- /dev/null +++ b/pkg/cmd/discussion/shared/categories.go @@ -0,0 +1,32 @@ +package shared + +import ( + "fmt" + "slices" + "strings" + + "github.com/cli/cli/v2/pkg/cmd/discussion/client" +) + +// MatchCategory finds a category by name or slug (case-insensitive). +// It prefers an exact slug match over a name match, so users are +// encouraged to use slugs for unambiguous lookups. +func MatchCategory(input string, categories []client.DiscussionCategory) (*client.DiscussionCategory, error) { + for i := range categories { + if strings.EqualFold(categories[i].Slug, input) { + return &categories[i], nil + } + } + for i := range categories { + if strings.EqualFold(categories[i].Name, input) { + return &categories[i], nil + } + } + + slugs := make([]string, len(categories)) + for i, c := range categories { + slugs[i] = c.Slug + } + slices.Sort(slugs) + return nil, fmt.Errorf("unknown category: %q; must be one of %q", input, slugs) +} diff --git a/pkg/cmd/discussion/shared/client.go b/pkg/cmd/discussion/shared/client.go new file mode 100644 index 00000000000..d0f34e04b78 --- /dev/null +++ b/pkg/cmd/discussion/shared/client.go @@ -0,0 +1,21 @@ +// Package shared provides factory functions, field definitions, and display +// helpers used across discussion subcommands. +package shared + +import ( + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" +) + +// DiscussionClientFunc returns a factory function that creates a DiscussionClient +// from the given Factory. The returned function is intended to be stored in +// command Options structs and called lazily inside RunE. +func DiscussionClientFunc(f *cmdutil.Factory) func() (client.DiscussionClient, error) { + return func() (client.DiscussionClient, error) { + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + return client.NewDiscussionClient(httpClient), nil + } +} diff --git a/pkg/cmd/discussion/shared/labels.go b/pkg/cmd/discussion/shared/labels.go new file mode 100644 index 00000000000..a0a7475045b --- /dev/null +++ b/pkg/cmd/discussion/shared/labels.go @@ -0,0 +1,36 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/pkg/cmd/discussion/client" +) + +// ResolveLabels matches user-provided label names (case-insensitive) against a +// set of known labels and returns the corresponding IDs. If any names cannot be +// matched, all unrecognized names are reported in the returned error. +func ResolveLabels(allLabels []client.DiscussionLabel, names []string) ([]string, error) { + byName := make(map[string]string, len(allLabels)) + for _, l := range allLabels { + byName[strings.ToLower(l.Name)] = l.ID + } + + var ids []string + var missing []string + + for _, name := range names { + id, ok := byName[strings.ToLower(name)] + if !ok { + missing = append(missing, name) + } else { + ids = append(ids, id) + } + } + + if len(missing) > 0 { + return nil, fmt.Errorf("labels not found: %s", strings.Join(missing, ", ")) + } + + return ids, nil +} diff --git a/pkg/cmd/discussion/shared/labels_test.go b/pkg/cmd/discussion/shared/labels_test.go new file mode 100644 index 00000000000..41c8d83750a --- /dev/null +++ b/pkg/cmd/discussion/shared/labels_test.go @@ -0,0 +1,73 @@ +package shared + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveLabels(t *testing.T) { + tests := []struct { + name string + allLabels []client.DiscussionLabel + names []string + wantIDs []string + wantErr string + }{ + { + name: "empty source labels and empty names", + allLabels: nil, + names: nil, + wantIDs: nil, + }, + { + name: "empty source labels with non-empty names", + allLabels: nil, + names: []string{"bug", "enhancement"}, + wantErr: "labels not found: bug, enhancement", + }, + { + name: "non-empty source labels with empty names", + allLabels: []client.DiscussionLabel{ + {ID: "L1", Name: "bug"}, + {ID: "L2", Name: "enhancement"}, + }, + names: nil, + wantIDs: nil, + }, + { + name: "all names match", + allLabels: []client.DiscussionLabel{ + {ID: "L1", Name: "bug"}, + {ID: "L2", Name: "Enhancement"}, + {ID: "L3", Name: "documentation"}, + }, + names: []string{"enhancement", "Bug"}, + wantIDs: []string{"L2", "L1"}, + }, + { + name: "some names missing", + allLabels: []client.DiscussionLabel{ + {ID: "L1", Name: "bug"}, + {ID: "L2", Name: "enhancement"}, + }, + names: []string{"bug", "invalid", "unknown"}, + wantErr: "labels not found: invalid, unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ids, err := ResolveLabels(tt.allLabels, tt.names) + if tt.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantIDs, ids) + } + }) + } +} diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go new file mode 100644 index 00000000000..e754568f05d --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup.go @@ -0,0 +1,43 @@ +package shared + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +var discussionURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/discussions/(\d+)$`) + +// ParseDiscussionArg parses a discussion number or URL from a command argument. +// It returns the discussion number and, if the argument was a URL, a repo override. +func ParseDiscussionArg(arg string) (int, ghrepo.Interface, error) { + if num, err := strconv.Atoi(arg); err == nil { + return num, nil, nil + } + + if len(arg) > 1 && arg[0] == '#' { + if num, err := strconv.Atoi(arg[1:]); err == nil { + return num, nil, nil + } + } + + u, err := url.Parse(arg) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return 0, nil, fmt.Errorf("invalid discussion argument: %q", arg) + } + + // Note that an HTTP URL is also okay, because we're just using the URL to find + // the discussion number, repo and host, and we wont be unsecure HTTP API calls. + + m := discussionURLRE.FindStringSubmatch(u.Path) + if m == nil { + return 0, nil, fmt.Errorf("invalid discussion URL: %q", arg) + } + + num, _ := strconv.Atoi(m[3]) + repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) + return num, repo, nil +} diff --git a/pkg/cmd/discussion/shared/lookup_test.go b/pkg/cmd/discussion/shared/lookup_test.go new file mode 100644 index 00000000000..0670efdd924 --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup_test.go @@ -0,0 +1,119 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDiscussionArg(t *testing.T) { + tests := []struct { + name string + arg string + wantNum int + wantOwner string + wantRepo string + wantHost string + wantErr string + }{ + { + name: "empty", + arg: "", + wantErr: `invalid discussion argument: ""`, + }, + { + name: "whitespaces", + arg: " ", + wantErr: `invalid discussion argument: " "`, + }, + { + name: "invalid string", + arg: "not-a-number", + wantErr: `invalid discussion argument: "not-a-number"`, + }, + { + name: "hash only", + arg: "#", + wantErr: `invalid discussion argument: "#"`, + }, + { + name: "hash non-numeric", + arg: "#abc", + wantErr: `invalid discussion argument: "#abc"`, + }, + { + name: "URL with wrong path", + arg: "https://github.com/owner/repo/issues/10", + wantErr: `invalid discussion URL: "https://github.com/owner/repo/issues/10"`, + }, + { + name: "URL missing number", + arg: "https://github.com/owner/repo/discussions/", + wantErr: `invalid discussion URL: "https://github.com/owner/repo/discussions/"`, + }, + { + name: "zero", + arg: "0", + wantNum: 0, + }, + { + name: "plain number", + arg: "42", + wantNum: 42, + }, + { + name: "hash number", + arg: "#99", + wantNum: 99, + }, + { + name: "HTTPS URL", + arg: "https://github.com/cli/cli/discussions/123", + wantNum: 123, + wantOwner: "cli", + wantRepo: "cli", + wantHost: "github.com", + }, + { + name: "HTTP URL", + arg: "http://github.com/owner/repo/discussions/7", + wantNum: 7, + wantOwner: "owner", + wantRepo: "repo", + wantHost: "github.com", + }, + { + name: "GHES URL", + arg: "https://git.example.com/org/project/discussions/55", + wantNum: 55, + wantOwner: "org", + wantRepo: "project", + wantHost: "git.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num, repo, err := ParseDiscussionArg(tt.arg) + + if tt.wantErr != "" { + require.Error(t, err) + assert.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantNum, num) + + if tt.wantOwner != "" || tt.wantRepo != "" || tt.wantHost != "" { + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + } else { + assert.Nil(t, repo) + } + }) + } +} diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go new file mode 100644 index 00000000000..8b8d1e24b30 --- /dev/null +++ b/pkg/cmd/discussion/view/view.go @@ -0,0 +1,532 @@ +package view + +import ( + "fmt" + "io" + "slices" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmd/discussion/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +var discussionFields = []string{ + "id", + "number", + "title", + "body", + "url", + "closed", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "comments", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} + +var reactionEmoji = map[string]string{ + "THUMBS_UP": "\U0001f44d", + "THUMBS_DOWN": "\U0001f44e", + "LAUGH": "\U0001f604", + "HOORAY": "\U0001f389", + "CONFUSED": "\U0001f615", + "HEART": "\u2764\ufe0f", + "ROCKET": "\U0001f680", + "EYES": "\U0001f440", +} + +func reactionGroupList(groups []client.ReactionGroup) string { + var parts []string + for _, g := range groups { + if g.TotalCount == 0 { + continue + } + emoji := reactionEmoji[g.Content] + if emoji == "" { + emoji = g.Content + } + parts = append(parts, fmt.Sprintf("%s %d", emoji, g.TotalCount)) + } + return strings.Join(parts, " • ") +} + +// ViewOptions holds the configuration for the view command. +type ViewOptions struct { + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + Client func() (client.DiscussionClient, error) + + DiscussionNumber int + WebMode bool + Comments bool + Replies string + Limit int + After string + Order string + Exporter cmdutil.Exporter + Now func() time.Time +} + +// NewCmdView creates the "discussion view" command. +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + Browser: f.Browser, + Now: time.Now, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View a discussion (preview)", + Long: heredoc.Docf(` + Display the title, body, and other information about a discussion. + + With %[1]s--comments%[1]s flag, show threaded comments on the discussion. + Use %[1]s--order%[1]s to control comment ordering (oldest or newest first). + Use %[1]s--limit%[1]s and %[1]s--after%[1]s for paginating through comments. + + With %[1]s--replies%[1]s flag, show paginated replies on a specific comment. + Pass the comment node ID (e.g. %[1]sDC_abc123%[1]s) to fetch its replies. + Use %[1]s--limit%[1]s, %[1]s--after%[1]s, and %[1]s--order%[1]s to control reply pagination. + + With %[1]s--web%[1]s flag, open the discussion in a web browser instead. + `, "`"), + Example: heredoc.Doc(` + # View a discussion by number + $ gh discussion view 123 + + # View a discussion by URL + $ gh discussion view https://github.com/OWNER/REPO/discussions/123 + + # View with comments + $ gh discussion view 123 --comments + + # View with oldest comments first + $ gh discussion view 123 --comments --order oldest + + # Limit to 10 comments + $ gh discussion view 123 --comments --limit 10 + + # Fetch the next page of comments + $ gh discussion view 123 --comments --after CURSOR + + # View replies on a specific comment + $ gh discussion view 123 --replies COMMENT-ID + + # Paginate through replies + $ gh discussion view 123 --replies COMMENT-ID --limit 10 --after CURSOR + + # Open in browser + $ gh discussion view 123 --web + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.MutuallyExclusive("specify only one of --comments, --replies, or --web", + opts.Comments, opts.Replies != "", opts.WebMode); err != nil { + return err + } + + repliesMode := opts.Replies != "" + commentsMode := needsComments(opts) + + paginatedMode := commentsMode || repliesMode + if cmd.Flags().Changed("order") && !paginatedMode { + return cmdutil.FlagErrorf("--order requires --comments or --replies") + } + if cmd.Flags().Changed("limit") && !paginatedMode { + return cmdutil.FlagErrorf("--limit requires --comments or --replies") + } + if cmd.Flags().Changed("after") && !paginatedMode { + return cmdutil.FlagErrorf("--after requires --comments or --replies") + } + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } + + number, repo, err := shared.ParseDiscussionArg(args[0]) + if err != nil { + return cmdutil.FlagErrorWrap(err) + } + + if repo != nil { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return repo, nil + } + } else { + opts.BaseRepo = f.BaseRepo + } + + opts.DiscussionNumber = number + opts.Client = shared.DiscussionClientFunc(f) + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open a discussion in the browser") + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View discussion comments") + cmd.Flags().StringVar(&opts.Replies, "replies", "", "View replies on a specific comment by its node ID") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments or replies to fetch") + cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of comments or replies") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionFields) + + return cmd +} + +// exporterNeedsComments returns true when the JSON exporter requests the comments field. +func exporterNeedsComments(exporter cmdutil.Exporter) bool { + return slices.Contains(exporter.Fields(), "comments") +} + +// needsComments returns true when the command should fetch full comment data, +// either because --comments was set or because --json requested the comments field. +func needsComments(opts *ViewOptions) bool { + return opts.Comments || opts.Exporter != nil && exporterNeedsComments(opts.Exporter) +} + +func viewRun(opts *ViewOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(repo, "discussions/%d", opts.DiscussionNumber) + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) + } + return opts.Browser.Browse(openURL) + } + + c, err := opts.Client() + if err != nil { + return err + } + + opts.IO.DetectTerminalTheme() + opts.IO.StartProgressIndicator() + + if opts.Replies != "" { + discussion, err := c.GetCommentReplies(repo, opts.DiscussionNumber, opts.Replies, opts.Limit, opts.After, opts.Order == "newest") + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, discussion) + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if len(discussion.Comments.Comments) == 0 { + return fmt.Errorf("no comment found for reply ID %s", opts.Replies) + } + comment := discussion.Comments.Comments[0] + if opts.IO.IsStdoutTTY() { + return printHumanReplies(opts, &comment) + } + return printRawReplies(opts.IO.Out, &comment) + } + + var discussion *client.Discussion + if needsComments(opts) { + discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, opts.Limit, opts.After, opts.Order == "newest") + } else { + discussion, err = c.GetByNumber(repo, opts.DiscussionNumber) + } + + opts.IO.StopProgressIndicator() + + if err != nil { + return err + } + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, discussion) + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.IO.IsStdoutTTY() { + return printHumanView(opts, discussion) + } + + return printRawView(opts.IO.Out, discussion, opts.Comments) +} + +func printHumanView(opts *ViewOptions, d *client.Discussion) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + numberStr := fmt.Sprintf("#%d", d.Number) + if !d.Closed { + numberStr = cs.Green(numberStr) + } else { + numberStr = cs.Muted(numberStr) + } + fmt.Fprintf(out, "%s %s\n", cs.Bold(d.Title), numberStr) + + state := "Open" + stateColor := cs.Green + if d.Closed { + state = "Closed" + stateColor = cs.Muted + } + + verb := "Started by" + if d.Category.IsAnswerable { + verb = "Asked by" + } + + fmt.Fprintf(out, "%s · %s · %s %s · %s · %s\n", + stateColor(state), + d.Category.Name, + verb, + d.Author.Login, + text.FuzzyAgo(opts.Now(), d.CreatedAt), + text.Pluralize(d.Comments.TotalCount, "comment"), + ) + + if labels := labelList(d.Labels, cs); labels != "" { + fmt.Fprint(out, cs.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + + var md string + if d.Body == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) + } else { + var err error + md, err = markdown.Render(d.Body, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(out, "\n%s\n", md) + + if reactions := reactionGroupList(d.ReactionGroups); reactions != "" { + fmt.Fprintln(out, reactions) + fmt.Fprintln(out) + } + + // Comments section + if opts.Comments && d.Comments.TotalCount > 0 { + fmt.Fprintln(out, cs.Bold("Comments")) + fmt.Fprintln(out) + + for _, c := range d.Comments.Comments { + if err := printHumanComment(opts, out, c, ""); err != nil { + return err + } + } + + if shown := len(d.Comments.Comments); shown < d.Comments.TotalCount { + remaining := d.Comments.TotalCount - shown + age := "more" + if d.Comments.Direction == client.DiscussionCommentListDirectionForward { + age = "newer" + } else if d.Comments.Direction == client.DiscussionCommentListDirectionBackward { + age = "older" + } + fmt.Fprintf(out, cs.Muted(" And %d %s comments\n"), remaining, age) + fmt.Fprintln(out) + } + + if d.Comments.NextCursor != "" { + fmt.Fprintf(out, cs.Muted("To see more comments, pass: --after %s\n"), d.Comments.NextCursor) + fmt.Fprintln(out) + } + } + + fmt.Fprintf(out, cs.Muted("View this discussion on GitHub: %s\n"), d.URL) + + return nil +} + +func printRawView(out io.Writer, d *client.Discussion, showComments bool) error { + fmt.Fprintf(out, "title:\t%s\n", d.Title) + state := "OPEN" + if d.Closed { + state = "CLOSED" + } + fmt.Fprintf(out, "state:\t%s\n", state) + fmt.Fprintf(out, "category:\t%s\n", d.Category.Name) + fmt.Fprintf(out, "author:\t%s\n", d.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labelList(d.Labels, nil)) + fmt.Fprintf(out, "comments:\t%d\n", d.Comments.TotalCount) + if showComments && d.Comments.NextCursor != "" { + fmt.Fprintf(out, "next:\t%s\n", d.Comments.NextCursor) + } + fmt.Fprintf(out, "number:\t%d\n", d.Number) + fmt.Fprintf(out, "url:\t%s\n", d.URL) + fmt.Fprintln(out, "--") + fmt.Fprintln(out, d.Body) + + if showComments { + for _, c := range d.Comments.Comments { + printRawComment(out, c, "") + } + } + + return nil +} + +func printHumanComment(opts *ViewOptions, out io.Writer, c client.DiscussionComment, indent string) error { + cs := opts.IO.ColorScheme() + now := opts.Now() + + header := fmt.Sprintf("%s%s commented %s", + indent, + cs.Bold(c.Author.Login), + text.FuzzyAgo(now, c.CreatedAt), + ) + if c.IsAnswer { + header += " " + cs.Green("✓ Answer") + } + fmt.Fprintln(out, header) + + if c.Body != "" { + md, err := markdown.Render(c.Body, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth())) + if err != nil { + return err + } + if indent != "" { + md = text.Indent(md, indent) + } + fmt.Fprint(out, md) + } + + if reactions := reactionGroupList(c.ReactionGroups); reactions != "" { + fmt.Fprintf(out, "%s%s\n", indent, reactions) + } + + fmt.Fprintln(out) + + for _, reply := range c.Replies.Comments { + if err := printHumanComment(opts, out, reply, indent+" "); err != nil { + return err + } + } + + if shown := len(c.Replies.Comments); shown < c.Replies.TotalCount { + directionLabel := "more" + if c.Replies.Direction == client.DiscussionCommentListDirectionForward { + directionLabel = "newer" + } else if c.Replies.Direction == client.DiscussionCommentListDirectionBackward { + directionLabel = "older" + } + fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d %s replies", c.Replies.TotalCount-shown, directionLabel))) + } + + return nil +} + +func printRawComment(out io.Writer, c client.DiscussionComment, indent string) { + answer := "" + if c.IsAnswer { + answer = "\tanswer" + } + fmt.Fprintf(out, "%scomment:\t%s\t%s\t%s%s\n", indent, c.Author.Login, c.CreatedAt.Format(time.RFC3339), c.URL, answer) + fmt.Fprintf(out, "%s--\n", indent) + if indent != "" { + fmt.Fprint(out, text.Indent(c.Body, indent)) + } else { + fmt.Fprint(out, c.Body) + } + fmt.Fprintln(out) + + for _, reply := range c.Replies.Comments { + printRawComment(out, reply, indent+" ") + } +} + +func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string { + if len(labels) == 0 { + return "" + } + + sortedLabels := slices.Clone(labels) + slices.SortStableFunc(sortedLabels, func(i, j client.DiscussionLabel) int { + return strings.Compare(i.Name, j.Name) + }) + + names := make([]string, len(sortedLabels)) + for i, l := range sortedLabels { + if cs == nil { + names[i] = l.Name + } else { + names[i] = cs.Label(l.Color, l.Name) + } + } + return strings.Join(names, ", ") +} + +func printHumanReplies(opts *ViewOptions, c *client.DiscussionComment) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + if err := printHumanComment(opts, out, *c, ""); err != nil { + return err + } + + if c.Replies.NextCursor != "" { + fmt.Fprintf(out, cs.Muted("To see more replies, pass: --after %s\n"), c.Replies.NextCursor) + fmt.Fprintln(out) + } + + return nil +} + +func printRawReplies(out io.Writer, c *client.DiscussionComment) error { + answer := "" + if c.IsAnswer { + answer = "\tanswer" + } + fmt.Fprintf(out, "comment:\t%s\t%s\t%s%s\n", c.Author.Login, c.CreatedAt.Format(time.RFC3339), c.URL, answer) + fmt.Fprintf(out, "replies:\t%d\n", c.Replies.TotalCount) + if c.Replies.NextCursor != "" { + fmt.Fprintf(out, "next:\t%s\n", c.Replies.NextCursor) + } + fmt.Fprintln(out, "--") + fmt.Fprintln(out, c.Body) + + for _, reply := range c.Replies.Comments { + printRawComment(out, reply, " ") + } + + return nil +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go new file mode 100644 index 00000000000..0c975f3c278 --- /dev/null +++ b/pkg/cmd/discussion/view/view_test.go @@ -0,0 +1,1155 @@ +package view + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/jsonfieldstest" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONFields(t *testing.T) { + jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdView, []string{ + "id", + "number", + "title", + "body", + "url", + "closed", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "comments", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", + }) +} + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + args string + wantErr string + wantOpts ViewOptions + wantRepo string + }{ + { + name: "number argument", + args: "123", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Limit: 30, + Order: "newest", + }, + }, + { + name: "hash number argument", + args: "'#456'", + wantOpts: ViewOptions{ + DiscussionNumber: 456, + Limit: 30, + Order: "newest", + }, + }, + { + name: "URL argument", + args: "https://github.com/OTHER/REPO/discussions/789", + wantOpts: ViewOptions{ + DiscussionNumber: 789, + Limit: 30, + Order: "newest", + }, + wantRepo: "OTHER/REPO", + }, + { + name: "invalid argument", + args: "not-a-number", + wantErr: "invalid discussion argument", + }, + { + name: "no arguments", + args: "", + wantErr: "accepts 1 arg(s), received 0", + }, + { + name: "web flag", + args: "123 --web", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + WebMode: true, + Limit: 30, + Order: "newest", + }, + }, + { + name: "comments flag", + args: "123 --comments", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "newest", + }, + }, + { + name: "comments with limit", + args: "123 --comments --limit 10", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Comments: true, + Limit: 10, + Order: "newest", + }, + }, + { + name: "comments with after", + args: "123 --comments --after CURSOR_ABC", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Comments: true, + Limit: 30, + After: "CURSOR_ABC", + Order: "newest", + }, + }, + { + name: "comments with order oldest", + args: "123 --comments --order oldest", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + }, + }, + { + name: "replies flag", + args: "123 --replies DC_abc", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Replies: "DC_abc", + Limit: 30, + Order: "newest", + }, + }, + { + name: "replies with limit", + args: "123 --replies DC_abc --limit 10", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Replies: "DC_abc", + Limit: 10, + Order: "newest", + }, + }, + { + name: "replies with after", + args: "123 --replies DC_abc --after CURSOR", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Replies: "DC_abc", + Limit: 30, + After: "CURSOR", + Order: "newest", + }, + }, + { + name: "replies with order oldest", + args: "123 --replies DC_abc --order oldest", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Replies: "DC_abc", + Limit: 30, + Order: "oldest", + }, + }, + { + name: "replies with comments is mutually exclusive", + args: "123 --replies DC_abc --comments", + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "replies with web is mutually exclusive", + args: "123 --replies DC_abc --web", + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "comments with web is mutually exclusive", + args: "123 --comments --web", + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "order requires comments or replies", + args: "123 --order newest", + wantErr: "--order requires --comments or --replies", + }, + { + name: "limit requires comments or replies", + args: "123 --limit 5", + wantErr: "--limit requires --comments or --replies", + }, + { + name: "after requires comments or replies", + args: "123 --after CURSOR", + wantErr: "--after requires --comments or --replies", + }, + { + name: "invalid limit zero", + args: "123 --comments --limit 0", + wantErr: "invalid limit", + }, + { + name: "invalid limit negative", + args: "123 --comments --limit -5", + wantErr: "invalid limit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + return nil + }) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + repo, err := gotOpts.BaseRepo() + require.NoError(t, err) + if tt.wantRepo != "" { + assert.Equal(t, tt.wantRepo, ghrepo.FullName(repo)) + } + assert.Equal(t, tt.wantOpts.DiscussionNumber, gotOpts.DiscussionNumber) + assert.Equal(t, tt.wantOpts.WebMode, gotOpts.WebMode) + assert.Equal(t, tt.wantOpts.Comments, gotOpts.Comments) + assert.Equal(t, tt.wantOpts.Replies, gotOpts.Replies) + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + assert.Equal(t, tt.wantOpts.After, gotOpts.After) + assert.Equal(t, tt.wantOpts.Order, gotOpts.Order) + }) + } +} + +func TestViewRun(t *testing.T) { + fixedNow := func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) } + + tests := []struct { + name string + tty bool + clientStub func(*testing.T, *client.DiscussionClientMock) + opts ViewOptions + wantStdout string + wantStderr string + wantBrowser string + }{ + { + name: "tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + return exampleAnswerableDiscussion(), nil + } + }, + wantStdout: heredoc.Doc(` + an interesting question #123 + Open · Q&A · Asked by monalisa · about 1 hour ago · 3 comments + Labels: help-wanted + + + about my interesting question + + + 👍 5 • 🚀 2 + + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), + }, + { + name: "nontty", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + return exampleAnswerableDiscussion(), nil + } + }, + wantStdout: heredoc.Doc(` + title: an interesting question + state: OPEN + category: Q&A + author: monalisa + labels: help-wanted + comments: 3 + number: 123 + url: https://github.com/OWNER/REPO/discussions/123 + -- + about my interesting question + `), + }, + { + name: "web", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + return exampleAnswerableDiscussion(), nil + } + }, + opts: ViewOptions{ + WebMode: true, + }, + wantStderr: "Opening https://github.com/OWNER/REPO/discussions/123 in your browser.\n", + wantBrowser: "https://github.com/OWNER/REPO/discussions/123", + }, + { + name: "not answerable tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + return exampleUnanswerableDiscussion(), nil + } + }, + wantStdout: heredoc.Doc(` + a cool discussion #123 + Open · General · Started by monalisa · about 1 hour ago · 3 comments + Labels: help-wanted + + + about my cool idea + + + 👍 5 • 🚀 2 + + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), + }, + { + name: "comments tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "", after) + assert.Equal(t, false, newest) + return exampleDiscussionWithComments(), nil + } + }, + opts: ViewOptions{ + Comments: true, + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + an interesting question #123 + Open · Q&A · Asked by monalisa · about 1 hour ago · 2 comments + Labels: help-wanted + + + about my interesting question + + + 👍 5 • 🚀 2 + + Comments + + octocat commented less than a minute ago ✓ Answer + + This is a comment + + 👍 3 + + hubot commented less than a minute ago + + Thanks! + + + And 4 more replies + + monalisa commented less than a minute ago + + Another comment + + + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), + }, + { + name: "comments nontty", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "", after) + assert.Equal(t, false, newest) + return exampleDiscussionWithComments(), nil + } + }, + opts: ViewOptions{ + Comments: true, + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + title: an interesting question + state: OPEN + category: Q&A + author: monalisa + labels: help-wanted + comments: 2 + number: 123 + url: https://github.com/OWNER/REPO/discussions/123 + -- + about my interesting question + comment: octocat 2025-03-02T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-1 answer + -- + This is a comment + comment: hubot 2025-03-02T01:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-2 + -- + Thanks! + comment: monalisa 2025-03-03T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-3 + -- + Another comment + `), + }, + { + name: "comments pagination tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + d := exampleDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_123" + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 10, commentLimit) + assert.Equal(t, "CURSOR_ABC", after) + assert.Equal(t, false, newest) + return d, nil + } + }, + opts: ViewOptions{ + Comments: true, + Limit: 10, + After: "CURSOR_ABC", + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + an interesting question #123 + Open · Q&A · Asked by monalisa · about 1 hour ago · 2 comments + Labels: help-wanted + + + about my interesting question + + + 👍 5 • 🚀 2 + + Comments + + octocat commented less than a minute ago ✓ Answer + + This is a comment + + 👍 3 + + hubot commented less than a minute ago + + Thanks! + + + And 4 more replies + + monalisa commented less than a minute ago + + Another comment + + + To see more comments, pass: --after NEXT_CURSOR_123 + + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), + }, + { + name: "comments pagination nontty", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + d := exampleDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_456" + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "", after) + assert.Equal(t, false, newest) + return d, nil + } + }, + opts: ViewOptions{ + Comments: true, + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + title: an interesting question + state: OPEN + category: Q&A + author: monalisa + labels: help-wanted + comments: 2 + next: NEXT_CURSOR_456 + number: 123 + url: https://github.com/OWNER/REPO/discussions/123 + -- + about my interesting question + comment: octocat 2025-03-02T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-1 answer + -- + This is a comment + comment: hubot 2025-03-02T01:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-2 + -- + Thanks! + comment: monalisa 2025-03-03T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-3 + -- + Another comment + `), + }, + { + name: "json without comments field", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + return exampleAnswerableDiscussion(), nil + } + }, + opts: ViewOptions{ + Exporter: jsonExporter("title", "url"), + }, + wantStdout: compactJSON(heredoc.Doc(` + { + "title": "an interesting question", + "url": "https://github.com/OWNER/REPO/discussions/123" + } + `)), + }, + { + name: "json with comments field", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + return exampleDiscussionWithComments(), nil + } + }, + opts: ViewOptions{ + Exporter: jsonExporter("comments"), + }, + wantStdout: compactJSON(heredoc.Doc(` + { + "comments": { + "nodes": [ + { + "author": {"id": "", "login": "octocat", "name": ""}, + "body": "This is a comment", + "createdAt": "2025-03-02T00:00:00Z", + "id": "C_1", + "isAnswer": true, + "reactionGroups": [ + {"content": "THUMBS_UP", "totalCount": 3} + ], + "replies": { + "nodes": [ + { + "author": {"id": "", "login": "hubot", "name": ""}, + "body": "Thanks!", + "createdAt": "2025-03-02T01:00:00Z", + "id": "C_1_R1", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2" + } + ], + "totalCount": 5 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1" + }, + { + "author": {"id": "", "login": "monalisa", "name": ""}, + "body": "Another comment", + "createdAt": "2025-03-03T00:00:00Z", + "id": "C_2", + "isAnswer": false, + "reactionGroups": [], + "replies": { + "nodes": [], + "totalCount": 0 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3" + } + ], + "totalCount": 2 + } + } + `)), + }, + { + name: "json with comments field pagination", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetWithCommentsFunc = func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + d := exampleDiscussionWithComments() + d.Comments.NextCursor = "NEXT_COM_CUR" + return d, nil + } + }, + opts: ViewOptions{ + Exporter: jsonExporter("comments"), + }, + wantStdout: compactJSON(heredoc.Doc(` + { + "comments": { + "next": "NEXT_COM_CUR", + "nodes": [ + { + "author": {"id": "", "login": "octocat", "name": ""}, + "body": "This is a comment", + "createdAt": "2025-03-02T00:00:00Z", + "id": "C_1", + "isAnswer": true, + "reactionGroups": [ + {"content": "THUMBS_UP", "totalCount": 3} + ], + "replies": { + "nodes": [ + { + "author": {"id": "", "login": "hubot", "name": ""}, + "body": "Thanks!", + "createdAt": "2025-03-02T01:00:00Z", + "id": "C_1_R1", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2" + } + ], + "totalCount": 5 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1" + }, + { + "author": {"id": "", "login": "monalisa", "name": ""}, + "body": "Another comment", + "createdAt": "2025-03-03T00:00:00Z", + "id": "C_2", + "isAnswer": false, + "reactionGroups": [], + "replies": { + "nodes": [], + "totalCount": 0 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3" + } + ], + "totalCount": 2 + } + } + `)), + }, + { + name: "replies tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + return exampleDiscussionWithReplies(""), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + }, + wantStdout: heredoc.Doc(` + octocat commented less than a minute ago ✓ Answer + + This is the parent comment + + 👍 3 + + hubot commented less than a minute ago + + First reply + + + monalisa commented less than a minute ago + + Second reply + + + `), + }, + { + name: "replies pagination tty", + tty: true, + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + return exampleDiscussionWithReplies("NEXT_CUR"), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + }, + wantStdout: heredoc.Doc(` + octocat commented less than a minute ago ✓ Answer + + This is the parent comment + + 👍 3 + + hubot commented less than a minute ago + + First reply + + + monalisa commented less than a minute ago + + Second reply + + + To see more replies, pass: --after NEXT_CUR + + `), + }, + { + name: "replies nontty", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, false, newest) + return exampleDiscussionWithReplies(""), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + comment: octocat 2025-03-02T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-1 answer + replies: 2 + -- + This is the parent comment + comment: hubot 2025-03-02T01:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-2 + -- + First reply + comment: monalisa 2025-03-02T02:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-3 + -- + Second reply + `), + }, + { + name: "replies pagination nontty", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, false, newest) + return exampleDiscussionWithReplies("NEXT_CUR_456"), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + Order: "oldest", + }, + wantStdout: heredoc.Doc(` + comment: octocat 2025-03-02T00:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-1 answer + replies: 2 + next: NEXT_CUR_456 + -- + This is the parent comment + comment: hubot 2025-03-02T01:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-2 + -- + First reply + comment: monalisa 2025-03-02T02:00:00Z https://github.com/OWNER/REPO/discussions/123#discussioncomment-3 + -- + Second reply + `), + }, + { + name: "replies json", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + return exampleDiscussionWithReplies(""), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + Exporter: jsonExporter("comments"), + }, + wantStdout: compactJSON(heredoc.Doc(` + { + "comments": { + "nodes": [ + { + "author": {"id": "", "login": "octocat", "name": ""}, + "body": "This is the parent comment", + "createdAt": "2025-03-02T00:00:00Z", + "id": "DC_abc", + "isAnswer": true, + "reactionGroups": [ + {"content": "THUMBS_UP", "totalCount": 3} + ], + "replies": { + "nodes": [ + { + "author": {"id": "", "login": "hubot", "name": ""}, + "body": "First reply", + "createdAt": "2025-03-02T01:00:00Z", + "id": "R1", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2" + }, + { + "author": {"id": "", "login": "monalisa", "name": ""}, + "body": "Second reply", + "createdAt": "2025-03-02T02:00:00Z", + "id": "R2", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3" + } + ], + "totalCount": 2 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1" + } + ], + "totalCount": 1 + } + } + `)), + }, + { + name: "replies json pagination", + clientStub: func(t *testing.T, m *client.DiscussionClientMock) { + m.GetCommentRepliesFunc = func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, "OWNER/REPO", ghrepo.FullName(repo)) + assert.Equal(t, 123, number) + assert.Equal(t, "DC_abc", commentID) + assert.Equal(t, 30, limit) + assert.Equal(t, "", after) + assert.Equal(t, true, newest) + return exampleDiscussionWithReplies("NEXT_REP_CUR"), nil + } + }, + opts: ViewOptions{ + Replies: "DC_abc", + Exporter: jsonExporter("comments"), + }, + wantStdout: compactJSON(heredoc.Doc(` + { + "comments": { + "nodes": [ + { + "author": {"id": "", "login": "octocat", "name": ""}, + "body": "This is the parent comment", + "createdAt": "2025-03-02T00:00:00Z", + "id": "DC_abc", + "isAnswer": true, + "reactionGroups": [ + {"content": "THUMBS_UP", "totalCount": 3} + ], + "replies": { + "next": "NEXT_REP_CUR", + "nodes": [ + { + "author": {"id": "", "login": "hubot", "name": ""}, + "body": "First reply", + "createdAt": "2025-03-02T01:00:00Z", + "id": "R1", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2" + }, + { + "author": {"id": "", "login": "monalisa", "name": ""}, + "body": "Second reply", + "createdAt": "2025-03-02T02:00:00Z", + "id": "R2", + "isAnswer": false, + "reactionGroups": [], + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3" + } + ], + "totalCount": 2 + }, + "upvoteCount": 0, + "url": "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1" + } + ], + "totalCount": 1 + } + } + `)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + + mock := &client.DiscussionClientMock{} + tt.clientStub(t, mock) + + b := &browser.Stub{} + + opts := tt.opts + opts.IO = ios + opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.Client = func() (client.DiscussionClient, error) { return mock, nil } + opts.Browser = b + opts.DiscussionNumber = 123 + opts.Now = fixedNow + if opts.Limit == 0 { + opts.Limit = 30 + } + if opts.Order == "" { + opts.Order = "newest" + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + if tt.wantBrowser != "" { + b.Verify(t, tt.wantBrowser) + } + }) + } +} + +func exampleDiscussionWithComments() *client.Discussion { + d := exampleAnswerableDiscussion() + d.Comments = client.DiscussionCommentList{ + TotalCount: 2, + Comments: []client.DiscussionComment{ + { + ID: "C_1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1", + Author: client.DiscussionActor{Login: "octocat"}, + Body: "This is a comment", + CreatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 3}, + }, + Replies: client.DiscussionCommentList{ + TotalCount: 5, + Comments: []client.DiscussionComment{ + { + ID: "C_1_R1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2", + Author: client.DiscussionActor{Login: "hubot"}, + Body: "Thanks!", + CreatedAt: time.Date(2025, 3, 2, 1, 0, 0, 0, time.UTC), + }, + }, + }, + }, + { + ID: "C_2", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3", + Author: client.DiscussionActor{Login: "monalisa"}, + Body: "Another comment", + CreatedAt: time.Date(2025, 3, 3, 0, 0, 0, 0, time.UTC), + }, + }, + } + return d +} + +func exampleDiscussionWithReplies(nextCursor string) *client.Discussion { + d := exampleAnswerableDiscussion() + d.Comments = client.DiscussionCommentList{ + TotalCount: 1, + Comments: []client.DiscussionComment{ + { + ID: "DC_abc", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1", + Author: client.DiscussionActor{Login: "octocat"}, + Body: "This is the parent comment", + CreatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 3}, + }, + Replies: client.DiscussionCommentList{ + TotalCount: 2, + NextCursor: nextCursor, + Comments: []client.DiscussionComment{ + { + ID: "R1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2", + Author: client.DiscussionActor{Login: "hubot"}, + Body: "First reply", + CreatedAt: time.Date(2025, 3, 2, 1, 0, 0, 0, time.UTC), + }, + { + ID: "R2", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3", + Author: client.DiscussionActor{Login: "monalisa"}, + Body: "Second reply", + CreatedAt: time.Date(2025, 3, 2, 2, 0, 0, 0, time.UTC), + }, + }, + }, + }, + }, + } + return d +} + +func exampleAnswerableDiscussion() *client.Discussion { + return &client.Discussion{ + ID: "D_123", + Number: 123, + Title: "an interesting question", + Body: "about my interesting question", + URL: "https://github.com/OWNER/REPO/discussions/123", + Closed: false, + Author: client.DiscussionActor{Login: "monalisa"}, + Category: client.DiscussionCategory{ + Name: "Q&A", Slug: "q-a", IsAnswerable: true, + }, + Labels: []client.DiscussionLabel{{Name: "help-wanted", Color: "0075ca"}}, + Answered: false, + Comments: client.DiscussionCommentList{TotalCount: 3}, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 5}, + {Content: "ROCKET", TotalCount: 2}, + }, + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + } +} + +func exampleUnanswerableDiscussion() *client.Discussion { + return &client.Discussion{ + ID: "D_123", + Number: 123, + Title: "a cool discussion", + Body: "about my cool idea", + URL: "https://github.com/OWNER/REPO/discussions/123", + Closed: false, + Author: client.DiscussionActor{Login: "monalisa"}, + Category: client.DiscussionCategory{ + Name: "General", Slug: "general", IsAnswerable: false, + }, + Labels: []client.DiscussionLabel{{Name: "help-wanted", Color: "0075ca"}}, + Answered: false, + Comments: client.DiscussionCommentList{TotalCount: 3}, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 5}, + {Content: "ROCKET", TotalCount: 2}, + }, + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + } +} + +func compactJSON(s string) string { + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(s)); err != nil { + panic(fmt.Sprintf("compactJSON: %v", err)) + } + return buf.String() + "\n" +} + +func jsonExporter(fields ...string) cmdutil.Exporter { + e := cmdutil.NewJSONExporter() + e.SetFields(fields) + return e +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568ed3..a4bcf89facc 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -20,6 +20,7 @@ import ( completionCmd "github.com/cli/cli/v2/pkg/cmd/completion" configCmd "github.com/cli/cli/v2/pkg/cmd/config" copilotCmd "github.com/cli/cli/v2/pkg/cmd/copilot" + discussionCmd "github.com/cli/cli/v2/pkg/cmd/discussion" extensionCmd "github.com/cli/cli/v2/pkg/cmd/extension" "github.com/cli/cli/v2/pkg/cmd/factory" gistCmd "github.com/cli/cli/v2/pkg/cmd/gist" @@ -157,6 +158,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(agentTaskCmd.NewCmdAgentTask(&repoResolvingCmdFactory)) cmd.AddCommand(browseCmd.NewCmdBrowse(&repoResolvingCmdFactory, nil)) + cmd.AddCommand(discussionCmd.NewCmdDiscussion(&repoResolvingCmdFactory)) cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) cmd.AddCommand(orgCmd.NewCmdOrg(&repoResolvingCmdFactory)) cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))