From 45c68b48da0df84f88b30d2e57c656106123497f Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Thu, 2 Apr 2026 10:34:14 -0500 Subject: [PATCH 01/81] Add discussion command group scaffolding Introduce the pkg/cmd/discussion/ package with: - DiscussionClient interface and domain types (client/) - Generated mock via moq (client/) - Factory function for lazy client creation (shared/) - JSON field definitions for --json output (shared/) - Root 'discussion' command registered in the core group The interface defines all planned operations (list, search, get, create, update, close, reopen, comment, lock, unlock, mark-answer, unmark-answer) with stub implementations that will be replaced as each subcommand is added in subsequent PRs. Domain types are intentionally separate from API types per review guidance. No JSON struct tags are used; serialization is handled by ExportData methods. Refs: cli/cli#12810, github/gh-cli-and-desktop#115 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client.go | 26 + pkg/cmd/discussion/client/client_impl.go | 76 +++ pkg/cmd/discussion/client/client_mock.go | 773 +++++++++++++++++++++++ pkg/cmd/discussion/client/types.go | 265 ++++++++ pkg/cmd/discussion/discussion.go | 33 + pkg/cmd/discussion/shared/client.go | 21 + pkg/cmd/discussion/shared/fields.go | 25 + pkg/cmd/root/root.go | 2 + 8 files changed, 1221 insertions(+) create mode 100644 pkg/cmd/discussion/client/client.go create mode 100644 pkg/cmd/discussion/client/client_impl.go create mode 100644 pkg/cmd/discussion/client/client_mock.go create mode 100644 pkg/cmd/discussion/client/types.go create mode 100644 pkg/cmd/discussion/discussion.go create mode 100644 pkg/cmd/discussion/shared/client.go create mode 100644 pkg/cmd/discussion/shared/fields.go diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go new file mode 100644 index 00000000000..b698dc89674 --- /dev/null +++ b/pkg/cmd/discussion/client/client.go @@ -0,0 +1,26 @@ +// 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, limit int) ([]Discussion, int, error) + Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) + GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) + GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) + ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, 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..e5db32dd5f7 --- /dev/null +++ b/pkg/cmd/discussion/client/client_impl.go @@ -0,0 +1,76 @@ +package client + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" +) + +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), + } +} + +func (c *discussionClient) List(_ ghrepo.Interface, _ ListFilters, _ int) ([]Discussion, int, error) { + return nil, 0, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Search(_ ghrepo.Interface, _ SearchFilters, _ int) ([]Discussion, int, error) { + return nil, 0, fmt.Errorf("not implemented") +} + +func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) ListCategories(_ ghrepo.Interface) ([]DiscussionCategory, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Close(_ ghrepo.Interface, _ string, _ CloseReason) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Reopen(_ ghrepo.Interface, _ string) (*Discussion, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) AddComment(_ ghrepo.Interface, _ string, _ string, _ string) (*DiscussionComment, error) { + return nil, fmt.Errorf("not implemented") +} + +func (c *discussionClient) Lock(_ ghrepo.Interface, _ string, _ string) error { + return fmt.Errorf("not implemented") +} + +func (c *discussionClient) Unlock(_ ghrepo.Interface, _ string) error { + return fmt.Errorf("not implemented") +} + +func (c *discussionClient) MarkAnswer(_ ghrepo.Interface, _ string) error { + return fmt.Errorf("not implemented") +} + +func (c *discussionClient) UnmarkAnswer(_ ghrepo.Interface, _ string) error { + return fmt.Errorf("not implemented") +} diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go new file mode 100644 index 00000000000..4f8227d5fb9 --- /dev/null +++ b/pkg/cmd/discussion/client/client_mock.go @@ -0,0 +1,773 @@ +// 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") +// }, +// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +// panic("mock out the GetWithComments method") +// }, +// ListFunc: func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { +// panic("mock out the List method") +// }, +// ListCategoriesFunc: func(repo ghrepo.Interface) ([]DiscussionCategory, error) { +// panic("mock out the ListCategories 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, limit int) ([]Discussion, int, 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) + + // GetWithCommentsFunc mocks the GetWithComments method. + GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) + + // ListFunc mocks the List method. + ListFunc func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) + + // ListCategoriesFunc mocks the ListCategories method. + ListCategoriesFunc func(repo ghrepo.Interface) ([]DiscussionCategory, 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, limit int) ([]Discussion, int, 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 + } + // 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 + // Order is the order argument value. + Order string + } + // 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 + // 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 + } + // 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 + // 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 + lockGetWithComments sync.RWMutex + lockList sync.RWMutex + lockListCategories sync.RWMutex + lockLock sync.RWMutex + lockMarkAnswer sync.RWMutex + lockReopen sync.RWMutex + lockSearch sync.RWMutex + lockUnlock sync.RWMutex + lockUnmarkAnswer sync.RWMutex + lockUpdate sync.RWMutex +} + +// AddComment calls AddCommentFunc. +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 +} + +// GetWithComments calls GetWithCommentsFunc. +func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*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 + Order string + }{ + Repo: repo, + Number: number, + CommentLimit: commentLimit, + Order: order, + } + mock.lockGetWithComments.Lock() + mock.calls.GetWithComments = append(mock.calls.GetWithComments, callInfo) + mock.lockGetWithComments.Unlock() + return mock.GetWithCommentsFunc(repo, number, commentLimit, order) +} + +// 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 + Order string +} { + var calls []struct { + Repo ghrepo.Interface + Number int + CommentLimit int + Order string + } + mock.lockGetWithComments.RLock() + calls = mock.calls.GetWithComments + mock.lockGetWithComments.RUnlock() + return calls +} + +// List calls ListFunc. +func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { + if mock.ListFunc == nil { + panic("DiscussionClientMock.ListFunc: method is nil but DiscussionClient.List was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Filters ListFilters + Limit int + }{ + Repo: repo, + Filters: filters, + Limit: limit, + } + mock.lockList.Lock() + mock.calls.List = append(mock.calls.List, callInfo) + mock.lockList.Unlock() + return mock.ListFunc(repo, filters, 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 + Limit int +} { + var calls []struct { + Repo ghrepo.Interface + Filters ListFilters + 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 +} + +// 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, limit int) ([]Discussion, int, error) { + if mock.SearchFunc == nil { + panic("DiscussionClientMock.SearchFunc: method is nil but DiscussionClient.Search was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Filters SearchFilters + Limit int + }{ + Repo: repo, + Filters: filters, + Limit: limit, + } + mock.lockSearch.Lock() + mock.calls.Search = append(mock.calls.Search, callInfo) + mock.lockSearch.Unlock() + return mock.SearchFunc(repo, filters, 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 + Limit int +} { + var calls []struct { + Repo ghrepo.Interface + Filters SearchFilters + 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..9870416ad87 --- /dev/null +++ b/pkg/cmd/discussion/client/types.go @@ -0,0 +1,265 @@ +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 + State string + StateReason string + Author DiscussionAuthor + Category DiscussionCategory + Labels []DiscussionLabel + Answered bool + AnswerChosenAt time.Time + AnswerChosenBy *DiscussionAuthor + 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 "state": + data[f] = d.State + 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() + } + data[f] = map[string]interface{}{ + "totalCount": d.Comments.TotalCount, + "nodes": comments, + } + case "reactionGroups": + groups := make([]interface{}, len(d.ReactionGroups)) + for i, rg := range d.ReactionGroups { + groups[i] = rg.Export() + } + data[f] = groups + 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 +} + +// DiscussionAuthor represents the author of a discussion or comment. +type DiscussionAuthor struct { + ID string + Login string + Name string +} + +// Export returns the author as a map for JSON output. +func (a DiscussionAuthor) 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 DiscussionAuthor + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []ReactionGroup + Replies []DiscussionComment + TotalReplies int +} + +// Export returns the comment as a map for JSON output. +func (c DiscussionComment) Export() map[string]interface{} { + replies := make([]interface{}, len(c.Replies)) + for i, r := range c.Replies { + replies[i] = r.Export() + } + 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, + "replies": replies, + "totalReplies": c.TotalReplies, + } +} + +// DiscussionCommentList represents a paginated list of comments on a discussion. +type DiscussionCommentList struct { + Comments []DiscussionComment + TotalCount int +} + +// 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" +) + +// ListFilters holds parameters for the repository.discussions query. +// CategoryID must be resolved by the caller before passing to List. +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. +type SearchFilters struct { + Author string + Labels []string + State string + Category string + Answered *bool + OrderBy string + Direction string +} + +// CreateDiscussionInput holds the parameters for creating a discussion. +type CreateDiscussionInput struct { + RepositoryID string + CategoryID string + Title string + Body 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 +} diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go new file mode 100644 index 00000000000..6db07c5d8bb --- /dev/null +++ b/pkg/cmd/discussion/discussion.go @@ -0,0 +1,33 @@ +package discussion + +import ( + "github.com/MakeNowJust/heredoc" + "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: "Manage discussions", + Long: "Work with GitHub Discussions.", + 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) + + return cmd +} 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/fields.go b/pkg/cmd/discussion/shared/fields.go new file mode 100644 index 00000000000..47d750314aa --- /dev/null +++ b/pkg/cmd/discussion/shared/fields.go @@ -0,0 +1,25 @@ +package shared + +// DiscussionFields lists the field names available for --json output on +// discussion commands. +var DiscussionFields = []string{ + "id", + "number", + "title", + "body", + "url", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "comments", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} 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)) From 90449b51970c05fe07ed5270ee75578c3f8568d4 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Thu, 2 Apr 2026 12:53:30 -0500 Subject: [PATCH 02/81] Implement `gh discussion list` command Add the discussion list command with full support for: - Listing discussions with state, category, answered, and order filters - Searching discussions by author and labels (uses Search API) - Category resolution by slug or name (case-insensitive) - TTY and non-TTY table output with colored IDs and labels - JSON output with totalCount envelope - Web mode (--web) to open discussions in browser - Pagination for large result sets Implement List, Search, and ListCategories methods on the DiscussionClient. List and Search use plain text GraphQL for flexible variable handling. ListCategories uses safely typed GraphQL via shurcooL/githubv4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 351 ++++++++++++++++- pkg/cmd/discussion/discussion.go | 5 + pkg/cmd/discussion/list/list.go | 311 +++++++++++++++ pkg/cmd/discussion/list/list_test.go | 466 +++++++++++++++++++++++ 4 files changed, 1127 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/discussion/list/list.go create mode 100644 pkg/cmd/discussion/list/list_test.go diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index e5db32dd5f7..567cce2afda 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -3,9 +3,12 @@ package client import ( "fmt" "net/http" + "strings" + "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type discussionClient struct { @@ -19,12 +22,314 @@ func NewDiscussionClient(httpClient *http.Client) DiscussionClient { } } -func (c *discussionClient) List(_ ghrepo.Interface, _ ListFilters, _ int) ([]Discussion, int, error) { - return nil, 0, fmt.Errorf("not implemented") +// discussionNode is the shared GraphQL response shape for a single discussion, +// used by both List and Search to avoid duplicating the field mapping. +type discussionNode struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + StateReason string `json:"stateReason"` + Author struct { + Login string `json:"login"` + } `json:"author"` + Category struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Emoji string `json:"emoji"` + IsAnswerable bool `json:"isAnswerable"` + } `json:"category"` + Labels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"nodes"` + } `json:"labels"` + IsAnswered bool `json:"isAnswered"` + AnswerChosenAt time.Time `json:"answerChosenAt"` + AnswerChosenBy *struct { + Login string `json:"login"` + } `json:"answerChosenBy"` + ReactionGroups []struct { + Content string `json:"content"` + Users struct { + TotalCount int `json:"totalCount"` + } `json:"users"` + } `json:"reactionGroups"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ClosedAt time.Time `json:"closedAt"` + Locked bool `json:"locked"` } -func (c *discussionClient) Search(_ ghrepo.Interface, _ SearchFilters, _ int) ([]Discussion, int, error) { - return nil, 0, fmt.Errorf("not implemented") +// mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. +func mapDiscussion(n discussionNode) Discussion { + d := Discussion{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + URL: n.URL, + State: n.State, + StateReason: n.StateReason, + Author: DiscussionAuthor{Login: n.Author.Login}, + 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 { + d.AnswerChosenBy = &DiscussionAuthor{Login: n.AnswerChosenBy.Login} + } + + 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} + } + + d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups)) + for i, rg := range n.ReactionGroups { + d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} + } + + return d +} + +// discussionFields is the GraphQL fragment selecting fields for discussion queries. +// It is shared by both List (repository.discussions) and Search queries. +const discussionFields = ` + id number title url state stateReason + author { login } + category { id name slug emoji isAnswerable } + labels(first: 20) { nodes { id name color } } + isAnswered answerChosenAt answerChosenBy { login } + reactionGroups { content users { totalCount } } + createdAt updatedAt closedAt locked +` + +func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { + type response struct { + Repository struct { + HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + Discussions struct { + TotalCount int `json:"totalCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []discussionNode `json:"nodes"` + } `json:"discussions"` + } `json:"repository"` + } + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + orderField := "UPDATED_AT" + orderDir := "DESC" + if filters.OrderBy != "" { + orderField = strings.ToUpper(filters.OrderBy) + "_AT" + } + if filters.Direction != "" { + orderDir = strings.ToUpper(filters.Direction) + } + variables["orderBy"] = map[string]string{ + "field": orderField, + "direction": orderDir, + } + + if filters.CategoryID != "" { + variables["categoryId"] = filters.CategoryID + } + + switch strings.ToLower(filters.State) { + case "open": + variables["states"] = []string{"OPEN"} + case "closed": + variables["states"] = []string{"CLOSED"} + } + + if filters.Answered != nil { + variables["answered"] = *filters.Answered + } + + // Build optional parameter declarations + paramParts := []string{ + "$owner: String!", + "$name: String!", + "$first: Int!", + "$after: String", + "$orderBy: DiscussionOrder", + } + argParts := []string{ + "first: $first", + "after: $after", + "orderBy: $orderBy", + } + if filters.CategoryID != "" { + paramParts = append(paramParts, "$categoryId: ID") + argParts = append(argParts, "categoryId: $categoryId") + } + if _, ok := variables["states"]; ok { + paramParts = append(paramParts, "$states: [DiscussionState!]") + argParts = append(argParts, "states: $states") + } + if filters.Answered != nil { + paramParts = append(paramParts, "$answered: Boolean") + argParts = append(argParts, "answered: $answered") + } + + query := fmt.Sprintf(`query DiscussionList(%s) { + repository(owner: $owner, name: $name) { + hasDiscussionsEnabled + discussions(%s) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { %s } + } + } + }`, strings.Join(paramParts, ", "), strings.Join(argParts, ", "), discussionFields) + + var discussions []Discussion + var totalCount int + pageLimit := limit + + for { + perPage := pageLimit + if perPage > 100 { + perPage = 100 + } + variables["first"] = perPage + + var data response + if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + return nil, 0, err + } + + if !data.Repository.HasDiscussionsEnabled { + return nil, 0, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + totalCount = data.Repository.Discussions.TotalCount + for _, n := range data.Repository.Discussions.Nodes { + discussions = append(discussions, mapDiscussion(n)) + } + + pageLimit -= len(data.Repository.Discussions.Nodes) + if pageLimit <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage { + break + } + variables["after"] = data.Repository.Discussions.PageInfo.EndCursor + } + + return discussions, totalCount, nil +} + +func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) { + type response struct { + Search struct { + DiscussionCount int `json:"discussionCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []discussionNode `json:"nodes"` + } `json:"search"` + } + + searchTerms := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())} + + switch strings.ToLower(filters.State) { + case "open": + searchTerms = append(searchTerms, "state:open") + case "closed": + searchTerms = append(searchTerms, "state:closed") + } + + if filters.Author != "" { + searchTerms = append(searchTerms, fmt.Sprintf("author:%s", filters.Author)) + } + for _, l := range filters.Labels { + searchTerms = append(searchTerms, fmt.Sprintf("label:%q", l)) + } + if filters.Category != "" { + searchTerms = append(searchTerms, fmt.Sprintf("category:%q", filters.Category)) + } + if filters.Answered != nil { + if *filters.Answered { + searchTerms = append(searchTerms, "is:answered") + } else { + searchTerms = append(searchTerms, "is:unanswered") + } + } + + orderField := "updated" + orderDir := "desc" + if filters.OrderBy != "" { + orderField = strings.ToLower(filters.OrderBy) + } + if filters.Direction != "" { + orderDir = strings.ToLower(filters.Direction) + } + searchTerms = append(searchTerms, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) + + searchQuery := strings.Join(searchTerms, " ") + + query := fmt.Sprintf(`query DiscussionSearch($query: String!, $first: Int!, $after: String) { + search(query: $query, type: DISCUSSION, first: $first, after: $after) { + discussionCount + pageInfo { hasNextPage endCursor } + nodes { ... on Discussion { %s } } + } + }`, discussionFields) + + variables := map[string]interface{}{ + "query": searchQuery, + } + + var discussions []Discussion + var totalCount int + pageLimit := limit + + for { + perPage := pageLimit + if perPage > 100 { + perPage = 100 + } + variables["first"] = perPage + + var data response + if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + return nil, 0, err + } + + totalCount = data.Search.DiscussionCount + for _, n := range data.Search.Nodes { + discussions = append(discussions, mapDiscussion(n)) + } + + pageLimit -= len(data.Search.Nodes) + if pageLimit <= 0 || !data.Search.PageInfo.HasNextPage { + break + } + variables["after"] = data.Search.PageInfo.EndCursor + } + + return discussions, totalCount, nil } func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { @@ -35,8 +340,42 @@ func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ s return nil, fmt.Errorf("not implemented") } -func (c *discussionClient) ListCategories(_ ghrepo.Interface) ([]DiscussionCategory, error) { - return nil, fmt.Errorf("not implemented") +func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { + var query struct { + Repository struct { + 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 + } + + categories := make([]DiscussionCategory, len(query.Repository.DiscussionCategories.Nodes)) + for i, n := range query.Repository.DiscussionCategories.Nodes { + categories[i] = DiscussionCategory{ + ID: n.ID, + Name: n.Name, + Slug: n.Slug, + Emoji: n.Emoji, + IsAnswerable: n.IsAnswerable, + } + } + + return categories, nil } func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) { diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index 6db07c5d8bb..bd724fe69e9 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -2,6 +2,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" + cmdList "github.com/cli/cli/v2/pkg/cmd/discussion/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -29,5 +30,9 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) + cmdutil.AddGroup(cmd, "General commands", + cmdList.NewCmdList(f, nil), + ) + return cmd } diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go new file mode 100644 index 00000000000..f82cc9806f4 --- /dev/null +++ b/pkg/cmd/discussion/list/list.go @@ -0,0 +1,311 @@ +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" +) + +// 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 + Order 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", + Short: "List discussions in a repository", + 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 answered discussions as JSON + $ gh discussion list --answered --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", 30, "Maximum number of discussions to fetch") + cmdutil.NilBoolFlag(cmd, &opts.Answered, "answered", "", "Filter by answered state") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "updated", []string{"created", "updated"}, "Order by field") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List discussions in the web browser") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields) + + return cmd +} + +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 := matchCategory(opts.Category, categories) + if err != nil { + return err + } + categoryID = cat.ID + categorySlug = cat.Slug + } + + var discussions []client.Discussion + var totalCount int + + useSearch := opts.Author != "" || len(opts.Labels) > 0 + if useSearch { + filters := client.SearchFilters{ + Author: opts.Author, + Labels: opts.Labels, + State: opts.State, + Category: categorySlug, + Answered: opts.Answered, + OrderBy: opts.Order, + } + discussions, totalCount, err = dc.Search(repo, filters, opts.Limit) + } else { + filters := client.ListFilters{ + State: opts.State, + CategoryID: categoryID, + Answered: opts.Answered, + OrderBy: opts.Order, + } + discussions, totalCount, err = dc.List(repo, filters, opts.Limit) + } + if err != nil { + return err + } + + if opts.Exporter != nil { + envelope := map[string]interface{}{ + "totalCount": totalCount, + "discussions": discussions, + } + return opts.Exporter.Write(opts.IO, envelope) + } + + if len(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) + } + + isTerminal := opts.IO.IsStdoutTTY() + if isTerminal { + title := listHeader(ghrepo.FullName(repo), len(discussions), totalCount, opts.State) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) + } + + printDiscussions(opts, discussions, totalCount) + return nil +} + +func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error { + discussionsURL := ghrepo.GenerateRepoURL(repo, "discussions") + + var queryParts []string + if opts.State != "" && opts.State != "all" { + queryParts = append(queryParts, "is:"+opts.State) + } + if opts.Author != "" { + queryParts = append(queryParts, "author:"+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 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 + } + } + + var available strings.Builder + for _, c := range categories { + fmt.Fprintf(&available, " %s (%s)\n", c.Slug, c.Name) + } + return nil, fmt.Errorf("category not found: %s\n\nAvailable categories:\n%s", input, available.String()) +} + +func noResults(repo ghrepo.Interface, state string) error { + stateQualifier := "" + switch state { + case "open": + stateQualifier = " open" + case "closed": + stateQualifier = " closed" + } + return cmdutil.NewNoResultsError(fmt.Sprintf("no%s discussions match your search in %s", stateQualifier, ghrepo.FullName(repo))) +} + +func listHeader(repoName string, count, total int, state string) string { + stateQualifier := "" + switch state { + case "open": + stateQualifier = " open" + case "closed": + stateQualifier = " closed" + } + return fmt.Sprintf("Showing %d of %d%s discussions in %s", count, total, stateQualifier, repoName) +} + +func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalCount int) { + isTerminal := opts.IO.IsStdoutTTY() + cs := opts.IO.ColorScheme() + now := opts.Now() + + 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 strings.EqualFold(d.State, "CLOSED") { + idColor = cs.Gray + } + tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor)) + } else { + tp.AddField(fmt.Sprintf("%d", d.Number)) + tp.AddField(d.State) + } + + 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() + + remaining := totalCount - len(discussions) + if 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..f4e6f57ab0d --- /dev/null +++ b/pkg/cmd/discussion/list/list_test.go @@ -0,0 +1,466 @@ +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", + State: "OPEN", + Author: client.DiscussionAuthor{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", + State: "OPEN", + Author: client.DiscussionAuthor{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 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, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, 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, + 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, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, 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, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + // Non-TTY output should not contain # prefix or header + 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, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, 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, + Exporter: exporter, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"discussions"`) +} + +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, limit int) ([]client.Discussion, int, error) { + return []client.Discussion{}, 0, 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, + 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, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, "CAT1", filters.CategoryID) + return sampleDiscussions()[:1], 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, + 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, + Now: fixedTime, + } + + err := listRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "category not found: nonexistent") + assert.Contains(t, err.Error(), "general (General)") +} + +func TestListRun_authorFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, "monalisa", filters.Author) + return sampleDiscussions()[:1], 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, + 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, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, []string{"bug", "docs"}, filters.Labels) + return sampleDiscussions()[:1], 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, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +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: "order flag", + args: "--order created", + }, + { + name: "invalid state", + args: "--state 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 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", + State: "CLOSED", + 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, limit int) ([]client.Discussion, int, error) { + return closed, 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, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "closed discussions") + assert.Contains(t, out, "Old discussion") + // Verify the # prefix is present (TTY mode) + assert.Contains(t, out, "#10") +} From e84ecb1585371594112ef1570a8c88b2d1968fda Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 13 Apr 2026 10:20:09 -0500 Subject: [PATCH 03/81] Address review feedback on discussion list PR - Export domain consts (FilterStateOpen/Closed, OrderByCreated/Updated, OrderDirectionAsc/Desc) in types.go - Change State fields to *string (nil = all states) - Add DiscussionListResult type with NextCursor for pagination - Update List/Search signatures: add after param, return result type - Add limit <= 0 guard clauses in client methods - Replace strings.ToUpper/ToLower with switch statements + default errors - Rename pageLimit to remaining, hoist hasDiscussionsEnabled check - Use qualifier/keyword terminology in search query building - Quote author values with %q for whitespace safety - Add Keywords string field (single string, not []string) - Split --order into --sort {created|updated} and --order {asc|desc} - Add --search/-S and --after flags - Add next field in JSON output envelope - Extract defaultLimit const - Add toFilterState helper for CLI-to-domain mapping - Move matchCategory to shared/categories.go with godoc - Use single-line error messages with sorted slugs - Add (preview) annotations to Short docs - Update Use to "list [flags]" - Add examples for --answered=false, --state all, multi-label - Update tests for new signatures and new flags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client.go | 4 +- pkg/cmd/discussion/client/client_impl.go | 151 +++++++++++++----- pkg/cmd/discussion/client/client_mock.go | 28 +++- pkg/cmd/discussion/client/types.go | 33 +++- pkg/cmd/discussion/discussion.go | 6 +- pkg/cmd/discussion/list/list.go | 101 +++++++----- pkg/cmd/discussion/list/list_test.go | 193 ++++++++++++++++++++--- pkg/cmd/discussion/shared/categories.go | 32 ++++ 8 files changed, 428 insertions(+), 120 deletions(-) create mode 100644 pkg/cmd/discussion/shared/categories.go diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index b698dc89674..7f5fdfd4b20 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -9,8 +9,8 @@ import "github.com/cli/cli/v2/internal/ghrepo" // DiscussionClient defines operations for interacting with the GitHub Discussions API. type DiscussionClient interface { - List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) - Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) + 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, order string) (*Discussion, error) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 567cce2afda..761913d2a5d 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -119,7 +119,11 @@ const discussionFields = ` createdAt updatedAt closedAt locked ` -func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { +func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) { + if limit <= 0 { + return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit) + } + type response struct { Repository struct { HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` @@ -142,10 +146,24 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limi orderField := "UPDATED_AT" orderDir := "DESC" if filters.OrderBy != "" { - orderField = strings.ToUpper(filters.OrderBy) + "_AT" + switch filters.OrderBy { + case OrderByCreated: + orderField = "CREATED_AT" + case OrderByUpdated: + orderField = "UPDATED_AT" + default: + return DiscussionListResult{}, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) + } } if filters.Direction != "" { - orderDir = strings.ToUpper(filters.Direction) + switch filters.Direction { + case OrderDirectionAsc: + orderDir = "ASC" + case OrderDirectionDesc: + orderDir = "DESC" + default: + return DiscussionListResult{}, fmt.Errorf("unknown order direction: %q", filters.Direction) + } } variables["orderBy"] = map[string]string{ "field": orderField, @@ -156,11 +174,15 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limi variables["categoryId"] = filters.CategoryID } - switch strings.ToLower(filters.State) { - case "open": - variables["states"] = []string{"OPEN"} - case "closed": - variables["states"] = []string{"CLOSED"} + if filters.State != nil { + switch *filters.State { + case FilterStateOpen: + variables["states"] = []string{"OPEN"} + case FilterStateClosed: + variables["states"] = []string{"CLOSED"} + default: + return DiscussionListResult{}, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) + } } if filters.Answered != nil { @@ -204,12 +226,20 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limi } }`, strings.Join(paramParts, ", "), strings.Join(argParts, ", "), discussionFields) + if after != "" { + variables["after"] = after + } + var discussions []Discussion var totalCount int - pageLimit := limit + var nextCursor string + remaining := limit + + // Check hasDiscussionsEnabled on first request only + firstPage := true for { - perPage := pageLimit + perPage := remaining if perPage > 100 { perPage = 100 } @@ -217,29 +247,41 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limi var data response if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { - return nil, 0, err + return DiscussionListResult{}, err } - if !data.Repository.HasDiscussionsEnabled { - return nil, 0, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + if firstPage && !data.Repository.HasDiscussionsEnabled { + return DiscussionListResult{}, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } + firstPage = false totalCount = data.Repository.Discussions.TotalCount for _, n := range data.Repository.Discussions.Nodes { discussions = append(discussions, mapDiscussion(n)) } - pageLimit -= len(data.Repository.Discussions.Nodes) - if pageLimit <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage { + remaining -= len(data.Repository.Discussions.Nodes) + if remaining <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage { + if data.Repository.Discussions.PageInfo.HasNextPage { + nextCursor = data.Repository.Discussions.PageInfo.EndCursor + } break } variables["after"] = data.Repository.Discussions.PageInfo.EndCursor } - return discussions, totalCount, nil + return DiscussionListResult{ + Discussions: discussions, + TotalCount: totalCount, + NextCursor: nextCursor, + }, nil } -func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) { +func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (DiscussionListResult, error) { + if limit <= 0 { + return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit) + } + type response struct { Search struct { DiscussionCount int `json:"discussionCount"` @@ -251,43 +293,60 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, } `json:"search"` } - searchTerms := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())} + qualifiers := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())} - switch strings.ToLower(filters.State) { - case "open": - searchTerms = append(searchTerms, "state:open") - case "closed": - searchTerms = append(searchTerms, "state:closed") + if filters.State != nil { + switch *filters.State { + case FilterStateOpen: + qualifiers = append(qualifiers, "state:open") + case FilterStateClosed: + qualifiers = append(qualifiers, "state:closed") + default: + return DiscussionListResult{}, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) + } } if filters.Author != "" { - searchTerms = append(searchTerms, fmt.Sprintf("author:%s", filters.Author)) + qualifiers = append(qualifiers, fmt.Sprintf("author:%q", filters.Author)) } for _, l := range filters.Labels { - searchTerms = append(searchTerms, fmt.Sprintf("label:%q", l)) + qualifiers = append(qualifiers, fmt.Sprintf("label:%q", l)) } if filters.Category != "" { - searchTerms = append(searchTerms, fmt.Sprintf("category:%q", filters.Category)) + qualifiers = append(qualifiers, fmt.Sprintf("category:%q", filters.Category)) } if filters.Answered != nil { if *filters.Answered { - searchTerms = append(searchTerms, "is:answered") + qualifiers = append(qualifiers, "is:answered") } else { - searchTerms = append(searchTerms, "is:unanswered") + qualifiers = append(qualifiers, "is:unanswered") } } - orderField := "updated" - orderDir := "desc" + orderField := OrderByUpdated + orderDir := OrderDirectionDesc if filters.OrderBy != "" { - orderField = strings.ToLower(filters.OrderBy) + switch filters.OrderBy { + case OrderByCreated, OrderByUpdated: + orderField = filters.OrderBy + default: + return DiscussionListResult{}, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) + } } if filters.Direction != "" { - orderDir = strings.ToLower(filters.Direction) + switch filters.Direction { + case OrderDirectionAsc, OrderDirectionDesc: + orderDir = filters.Direction + default: + return DiscussionListResult{}, fmt.Errorf("unknown order direction: %q", filters.Direction) + } } - searchTerms = append(searchTerms, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) + qualifiers = append(qualifiers, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) - searchQuery := strings.Join(searchTerms, " ") + searchQuery := strings.Join(qualifiers, " ") + if filters.Keywords != "" { + searchQuery += " " + filters.Keywords + } query := fmt.Sprintf(`query DiscussionSearch($query: String!, $first: Int!, $after: String) { search(query: $query, type: DISCUSSION, first: $first, after: $after) { @@ -301,12 +360,17 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, "query": searchQuery, } + if after != "" { + variables["after"] = after + } + var discussions []Discussion var totalCount int - pageLimit := limit + var nextCursor string + remaining := limit for { - perPage := pageLimit + perPage := remaining if perPage > 100 { perPage = 100 } @@ -314,7 +378,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, var data response if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { - return nil, 0, err + return DiscussionListResult{}, err } totalCount = data.Search.DiscussionCount @@ -322,14 +386,21 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, discussions = append(discussions, mapDiscussion(n)) } - pageLimit -= len(data.Search.Nodes) - if pageLimit <= 0 || !data.Search.PageInfo.HasNextPage { + remaining -= len(data.Search.Nodes) + if remaining <= 0 || !data.Search.PageInfo.HasNextPage { + if data.Search.PageInfo.HasNextPage { + nextCursor = data.Search.PageInfo.EndCursor + } break } variables["after"] = data.Search.PageInfo.EndCursor } - return discussions, totalCount, nil + return DiscussionListResult{ + Discussions: discussions, + TotalCount: totalCount, + NextCursor: nextCursor, + }, nil } func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index 4f8227d5fb9..589258b4ff7 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -33,7 +33,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { // panic("mock out the GetWithComments method") // }, -// ListFunc: func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { +// 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) { @@ -48,7 +48,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // ReopenFunc: func(repo ghrepo.Interface, id string) (*Discussion, error) { // panic("mock out the Reopen method") // }, -// SearchFunc: func(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) { +// 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 { @@ -83,7 +83,7 @@ type DiscussionClientMock struct { GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) // ListFunc mocks the List method. - ListFunc func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) + 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) @@ -98,7 +98,7 @@ type DiscussionClientMock struct { ReopenFunc func(repo ghrepo.Interface, id string) (*Discussion, error) // SearchFunc mocks the Search method. - SearchFunc func(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) + 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 @@ -162,6 +162,8 @@ type DiscussionClientMock struct { 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 } @@ -199,6 +201,8 @@ type DiscussionClientMock struct { 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 } @@ -441,23 +445,25 @@ func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct { } // List calls ListFunc. -func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { +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, limit) + return mock.ListFunc(repo, filters, after, limit) } // ListCalls gets all the calls that were made to List. @@ -467,11 +473,13 @@ func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilter 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() @@ -625,23 +633,25 @@ func (mock *DiscussionClientMock) ReopenCalls() []struct { } // Search calls SearchFunc. -func (mock *DiscussionClientMock) Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) { +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, limit) + return mock.SearchFunc(repo, filters, after, limit) } // SearchCalls gets all the calls that were made to Search. @@ -651,11 +661,13 @@ func (mock *DiscussionClientMock) Search(repo ghrepo.Interface, filters SearchFi 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() diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 9870416ad87..15cd459368e 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -225,10 +225,37 @@ const ( 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 + State *string CategoryID string Answered *bool OrderBy string @@ -237,12 +264,14 @@ type ListFilters struct { // 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 + State *string Category string Answered *bool + Keywords string OrderBy string Direction string } diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index bd724fe69e9..a9bf9b98120 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -11,8 +11,10 @@ import ( func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "discussion ", - Short: "Manage discussions", - Long: "Work with GitHub Discussions.", + 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" diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index f82cc9806f4..4a1c5b509d0 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -18,6 +18,8 @@ import ( "github.com/spf13/cobra" ) +const defaultLimit = 30 + // ListOptions holds the configuration for the discussion list command. type ListOptions struct { IO *iostreams.IOStreams @@ -31,7 +33,10 @@ type ListOptions struct { State string Limit int Answered *bool + Sort string Order string + Search string + After string WebMode bool Exporter cmdutil.Exporter @@ -47,8 +52,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } cmd := &cobra.Command{ - Use: "list", - Short: "List discussions in a repository", + 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. @@ -58,13 +63,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman $ gh discussion list # List discussions with a specific category - $ gh discussion list --category "General" + $ 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, @@ -87,15 +98,33 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman 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", 30, "Maximum number of discussions to fetch") + 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.Order, "order", "", "updated", []string{"created", "updated"}, "Order by field") + 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, shared.DiscussionFields) 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 { @@ -118,7 +147,7 @@ func listRun(opts *ListOptions) error { if err != nil { return err } - cat, err := matchCategory(opts.Category, categories) + cat, err := shared.MatchCategory(opts.Category, categories) if err != nil { return err } @@ -126,28 +155,32 @@ func listRun(opts *ListOptions) error { categorySlug = cat.Slug } - var discussions []client.Discussion - var totalCount int + state := toFilterState(opts.State) + + var result client.DiscussionListResult - useSearch := opts.Author != "" || len(opts.Labels) > 0 + useSearch := opts.Author != "" || len(opts.Labels) > 0 || opts.Search != "" if useSearch { filters := client.SearchFilters{ - Author: opts.Author, - Labels: opts.Labels, - State: opts.State, - Category: categorySlug, - Answered: opts.Answered, - OrderBy: opts.Order, + Author: opts.Author, + Labels: opts.Labels, + State: state, + Category: categorySlug, + Answered: opts.Answered, + Keywords: opts.Search, + OrderBy: opts.Sort, + Direction: opts.Order, } - discussions, totalCount, err = dc.Search(repo, filters, opts.Limit) + result, err = dc.Search(repo, filters, opts.After, opts.Limit) } else { filters := client.ListFilters{ - State: opts.State, + State: state, CategoryID: categoryID, Answered: opts.Answered, - OrderBy: opts.Order, + OrderBy: opts.Sort, + Direction: opts.Order, } - discussions, totalCount, err = dc.List(repo, filters, opts.Limit) + result, err = dc.List(repo, filters, opts.After, opts.Limit) } if err != nil { return err @@ -155,13 +188,14 @@ func listRun(opts *ListOptions) error { if opts.Exporter != nil { envelope := map[string]interface{}{ - "totalCount": totalCount, - "discussions": discussions, + "totalCount": result.TotalCount, + "discussions": result.Discussions, + "next": result.NextCursor, } return opts.Exporter.Write(opts.IO, envelope) } - if len(discussions) == 0 { + if len(result.Discussions) == 0 { return noResults(repo, opts.State) } @@ -173,11 +207,11 @@ func listRun(opts *ListOptions) error { isTerminal := opts.IO.IsStdoutTTY() if isTerminal { - title := listHeader(ghrepo.FullName(repo), len(discussions), totalCount, opts.State) + title := listHeader(ghrepo.FullName(repo), len(result.Discussions), result.TotalCount, opts.State) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } - printDiscussions(opts, discussions, totalCount) + printDiscussions(opts, result.Discussions, result.TotalCount) return nil } @@ -215,25 +249,6 @@ func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error { return opts.Browser.Browse(discussionsURL) } -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 - } - } - - var available strings.Builder - for _, c := range categories { - fmt.Fprintf(&available, " %s (%s)\n", c.Slug, c.Name) - } - return nil, fmt.Errorf("category not found: %s\n\nAvailable categories:\n%s", input, available.String()) -} - func noResults(repo ghrepo.Interface, state string) error { stateQualifier := "" switch state { diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go index f4e6f57ab0d..9404ac80669 100644 --- a/pkg/cmd/discussion/list/list_test.go +++ b/pkg/cmd/discussion/list/list_test.go @@ -55,6 +55,13 @@ func sampleDiscussions() []client.Discussion { } } +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}, @@ -69,8 +76,8 @@ func TestListRun_tty(t *testing.T) { ios.SetStderrTTY(true) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { - return sampleDiscussions(), 2, nil + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + return sampleResult(), nil }, } @@ -80,6 +87,8 @@ func TestListRun_tty(t *testing.T) { Client: func() (client.DiscussionClient, error) { return mockClient, nil }, State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -102,8 +111,8 @@ func TestListRun_nontty(t *testing.T) { ios.SetStdoutTTY(false) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { - return sampleDiscussions(), 2, nil + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + return sampleResult(), nil }, } @@ -113,6 +122,8 @@ func TestListRun_nontty(t *testing.T) { Client: func() (client.DiscussionClient, error) { return mockClient, nil }, State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -120,7 +131,6 @@ func TestListRun_nontty(t *testing.T) { require.NoError(t, err) out := stdout.String() - // Non-TTY output should not contain # prefix or header assert.NotContains(t, out, "Showing") assert.Contains(t, out, "42") assert.Contains(t, out, "OPEN") @@ -131,8 +141,12 @@ func TestListRun_json(t *testing.T) { ios, _, stdout, _ := iostreams.Test() mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { - return sampleDiscussions(), 2, nil + 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 }, } @@ -145,6 +159,8 @@ func TestListRun_json(t *testing.T) { Client: func() (client.DiscussionClient, error) { return mockClient, nil }, State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Exporter: exporter, Now: fixedTime, } @@ -155,6 +171,7 @@ func TestListRun_json(t *testing.T) { out := stdout.String() assert.Contains(t, out, `"totalCount"`) assert.Contains(t, out, `"discussions"`) + assert.Contains(t, out, `"next"`) } func TestListRun_web(t *testing.T) { @@ -184,8 +201,8 @@ func TestListRun_noResults(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { - return []client.Discussion{}, 0, nil + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + return client.DiscussionListResult{}, nil }, } @@ -195,6 +212,8 @@ func TestListRun_noResults(t *testing.T) { Client: func() (client.DiscussionClient, error) { return mockClient, nil }, State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -212,9 +231,12 @@ func TestListRun_categoryFilter(t *testing.T) { ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { return sampleCategories(), nil }, - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { assert.Equal(t, "CAT1", filters.CategoryID) - return sampleDiscussions()[:1], 1, nil + return client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil }, } @@ -225,6 +247,8 @@ func TestListRun_categoryFilter(t *testing.T) { Category: "general", State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -249,13 +273,14 @@ func TestListRun_categoryNotFound(t *testing.T) { Category: "nonexistent", State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } err := listRun(opts) require.Error(t, err) - assert.Contains(t, err.Error(), "category not found: nonexistent") - assert.Contains(t, err.Error(), "general (General)") + assert.Contains(t, err.Error(), `unknown category: "nonexistent"`) } func TestListRun_authorFilter(t *testing.T) { @@ -263,9 +288,12 @@ func TestListRun_authorFilter(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, limit int) ([]client.Discussion, int, error) { + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (client.DiscussionListResult, error) { assert.Equal(t, "monalisa", filters.Author) - return sampleDiscussions()[:1], 1, nil + return client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil }, } @@ -276,6 +304,8 @@ func TestListRun_authorFilter(t *testing.T) { Author: "monalisa", State: "open", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -289,9 +319,12 @@ func TestListRun_labelFilter(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, limit int) ([]client.Discussion, int, error) { + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (client.DiscussionListResult, error) { assert.Equal(t, []string{"bug", "docs"}, filters.Labels) - return sampleDiscussions()[:1], 1, nil + return client.DiscussionListResult{ + Discussions: sampleDiscussions()[:1], + TotalCount: 1, + }, nil }, } @@ -302,6 +335,39 @@ func TestListRun_labelFilter(t *testing.T) { 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, } @@ -310,6 +376,33 @@ func TestListRun_labelFilter(t *testing.T) { 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 @@ -349,15 +442,41 @@ func TestNewCmdList(t *testing.T) { name: "web flag", args: "--web", }, + { + name: "sort flag", + args: "--sort created", + }, { name: "order flag", - args: "--order created", + 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 { @@ -365,8 +484,8 @@ func TestNewCmdList(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 }, + Browser: &browser.Stub{}, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, } var gotOpts *ListOptions @@ -395,6 +514,30 @@ func TestNewCmdList(t *testing.T) { } } +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) { @@ -441,8 +584,11 @@ func TestListRun_closedState(t *testing.T) { } mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { - return closed, 1, nil + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + return client.DiscussionListResult{ + Discussions: closed, + TotalCount: 1, + }, nil }, } @@ -452,6 +598,8 @@ func TestListRun_closedState(t *testing.T) { Client: func() (client.DiscussionClient, error) { return mockClient, nil }, State: "closed", Limit: 30, + Sort: "updated", + Order: "desc", Now: fixedTime, } @@ -461,6 +609,5 @@ func TestListRun_closedState(t *testing.T) { out := stdout.String() assert.Contains(t, out, "closed discussions") assert.Contains(t, out, "Old discussion") - // Verify the # prefix is present (TTY mode) 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..a7893f229db --- /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 %v", input, slugs) +} From a08f5f22f03210699e551a8a909a74e46dba2556 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 13 Apr 2026 10:24:52 -0500 Subject: [PATCH 04/81] Fix gofmt alignment in test file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/list/list_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go index 9404ac80669..20702a2a5cf 100644 --- a/pkg/cmd/discussion/list/list_test.go +++ b/pkg/cmd/discussion/list/list_test.go @@ -484,8 +484,8 @@ func TestNewCmdList(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 }, + Browser: &browser.Stub{}, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, } var gotOpts *ListOptions From 17238050d7a45497a02817166f361ce1fadc03ec Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 13 Apr 2026 10:38:51 -0500 Subject: [PATCH 05/81] Check hasDiscussionsEnabled in ListCategories When --category is used, ListCategories runs before the List query. On repos with discussions disabled, it silently returns empty categories, leading to a confusing "must be one of []" error. Now it checks hasDiscussionsEnabled and returns the standard "discussions disabled" error, matching the behavior of List and Search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 761913d2a5d..60c02c8d5ad 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -414,7 +414,8 @@ func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ s func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { var query struct { Repository struct { - DiscussionCategories struct { + HasDiscussionsEnabled bool + DiscussionCategories struct { Nodes []struct { ID string Name string @@ -435,6 +436,10 @@ func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCa 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{ From 0687a29e51631e5c3f2e448716cee159d27f76ab Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 13 Apr 2026 11:51:22 -0500 Subject: [PATCH 06/81] Fix GQL schema: Discussion uses closed bool, not state string The GraphQL Discussion type has a `closed` boolean field, not a `state` string. Updated the API response struct and GQL fragment to query `closed` instead of `state`, and derive the domain-level State string ("OPEN"/"CLOSED") from the boolean in mapDiscussion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 60c02c8d5ad..8c68257850f 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -29,7 +29,7 @@ type discussionNode struct { Number int `json:"number"` Title string `json:"title"` URL string `json:"url"` - State string `json:"state"` + Closed bool `json:"closed"` StateReason string `json:"stateReason"` Author struct { Login string `json:"login"` @@ -67,12 +67,17 @@ type discussionNode struct { // mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. func mapDiscussion(n discussionNode) Discussion { + state := "OPEN" + if n.Closed { + state = "CLOSED" + } + d := Discussion{ ID: n.ID, Number: n.Number, Title: n.Title, URL: n.URL, - State: n.State, + State: state, StateReason: n.StateReason, Author: DiscussionAuthor{Login: n.Author.Login}, Category: DiscussionCategory{ @@ -110,7 +115,7 @@ func mapDiscussion(n discussionNode) Discussion { // discussionFields is the GraphQL fragment selecting fields for discussion queries. // It is shared by both List (repository.discussions) and Search queries. const discussionFields = ` - id number title url state stateReason + id number title url closed stateReason author { login } category { id name slug emoji isAnswerable } labels(first: 20) { nodes { id name color } } From 3f52503a67a74dab7fe551cf6d2a15366cb4d44e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 11:45:55 +0100 Subject: [PATCH 07/81] fix(discussion list): use separate list of fields Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 4a1c5b509d0..e45f784a8ef 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -20,6 +20,27 @@ import ( const defaultLimit = 30 +var discussionListFields = []string{ + "id", + "number", + "title", + "body", + "url", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} + // ListOptions holds the configuration for the discussion list command. type ListOptions struct { IO *iostreams.IOStreams @@ -105,7 +126,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman 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, shared.DiscussionFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionListFields) return cmd } From 236224dc44b88a40eafed0a67b9c2f7b17a4faff Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 11:52:52 +0100 Subject: [PATCH 08/81] fix(discussion list): replace state with closed in domain types Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 7 +------ pkg/cmd/discussion/client/types.go | 6 +++--- pkg/cmd/discussion/list/list.go | 13 ++++++++++--- pkg/cmd/discussion/list/list_test.go | 4 +--- pkg/cmd/discussion/shared/fields.go | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 8c68257850f..597303befa9 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -67,17 +67,12 @@ type discussionNode struct { // mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. func mapDiscussion(n discussionNode) Discussion { - state := "OPEN" - if n.Closed { - state = "CLOSED" - } - d := Discussion{ ID: n.ID, Number: n.Number, Title: n.Title, URL: n.URL, - State: state, + Closed: n.Closed, StateReason: n.StateReason, Author: DiscussionAuthor{Login: n.Author.Login}, Category: DiscussionCategory{ diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 15cd459368e..d0fc0fadff8 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -10,7 +10,7 @@ type Discussion struct { Title string Body string URL string - State string + Closed bool StateReason string Author DiscussionAuthor Category DiscussionCategory @@ -43,8 +43,8 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { data[f] = d.Body case "url": data[f] = d.URL - case "state": - data[f] = d.State + case "closed": + data[f] = d.Closed case "stateReason": data[f] = d.StateReason case "author": diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index e45f784a8ef..119d3ff9d6d 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -20,13 +20,16 @@ import ( 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", - "state", + "closed", "stateReason", "author", "category", @@ -306,13 +309,17 @@ func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalC for _, d := range discussions { if isTerminal { idColor := cs.Green - if strings.EqualFold(d.State, "CLOSED") { + if d.Closed { idColor = cs.Gray } tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor)) } else { tp.AddField(fmt.Sprintf("%d", d.Number)) - tp.AddField(d.State) + if d.Closed { + tp.AddField("CLOSED") + } else { + tp.AddField("OPEN") + } } tp.AddField(text.RemoveExcessiveWhitespace(d.Title)) diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go index 20702a2a5cf..72cc92a891e 100644 --- a/pkg/cmd/discussion/list/list_test.go +++ b/pkg/cmd/discussion/list/list_test.go @@ -24,7 +24,6 @@ func sampleDiscussions() []client.Discussion { Number: 42, Title: "Bug report discussion", URL: "https://github.com/OWNER/REPO/discussions/42", - State: "OPEN", Author: client.DiscussionAuthor{Login: "monalisa"}, Category: client.DiscussionCategory{ ID: "CAT1", @@ -41,7 +40,6 @@ func sampleDiscussions() []client.Discussion { Number: 41, Title: "Feature request", URL: "https://github.com/OWNER/REPO/discussions/41", - State: "OPEN", Author: client.DiscussionAuthor{Login: "octocat"}, Category: client.DiscussionCategory{ ID: "CAT2", @@ -576,7 +574,7 @@ func TestListRun_closedState(t *testing.T) { { Number: 10, Title: "Old discussion", - State: "CLOSED", + Closed: true, Category: client.DiscussionCategory{Name: "General"}, Labels: []client.DiscussionLabel{}, UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), diff --git a/pkg/cmd/discussion/shared/fields.go b/pkg/cmd/discussion/shared/fields.go index 47d750314aa..0feb789d9f2 100644 --- a/pkg/cmd/discussion/shared/fields.go +++ b/pkg/cmd/discussion/shared/fields.go @@ -8,7 +8,7 @@ var DiscussionFields = []string{ "title", "body", "url", - "state", + "closed", "stateReason", "author", "category", From 15d3eeae06126f2773d7de87d0a851eb6fc36bac Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 11:53:36 +0100 Subject: [PATCH 09/81] fix(discussion/client): fetch body in list/search Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 597303befa9..5f4117cf96d 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -110,7 +110,7 @@ func mapDiscussion(n discussionNode) Discussion { // discussionFields is the GraphQL fragment selecting fields for discussion queries. // It is shared by both List (repository.discussions) and Search queries. const discussionFields = ` - id number title url closed stateReason + id number title body url closed stateReason author { login } category { id name slug emoji isAnswerable } labels(first: 20) { nodes { id name color } } From 034e38f0e751c36d6e302caa331084f75caea7aa Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 12:11:00 +0100 Subject: [PATCH 10/81] fix(discussion/client): fetch author/answerChosenBy fields Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 20 ++++++++++++++++---- pkg/cmd/discussion/client/types.go | 12 ++++++------ pkg/cmd/discussion/list/list_test.go | 4 ++-- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 5f4117cf96d..27600922182 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -33,6 +33,8 @@ type discussionNode struct { StateReason string `json:"stateReason"` Author struct { Login string `json:"login"` + ID string `json:"id"` + Name string `json:"name"` } `json:"author"` Category struct { ID string `json:"id"` @@ -52,6 +54,8 @@ type discussionNode struct { AnswerChosenAt time.Time `json:"answerChosenAt"` AnswerChosenBy *struct { Login string `json:"login"` + ID string `json:"id"` + Name string `json:"name"` } `json:"answerChosenBy"` ReactionGroups []struct { Content string `json:"content"` @@ -74,7 +78,11 @@ func mapDiscussion(n discussionNode) Discussion { URL: n.URL, Closed: n.Closed, StateReason: n.StateReason, - Author: DiscussionAuthor{Login: n.Author.Login}, + Author: DiscussionActor{ + ID: n.Author.ID, + Login: n.Author.Login, + Name: n.Author.Name, + }, Category: DiscussionCategory{ ID: n.Category.ID, Name: n.Category.Name, @@ -91,7 +99,11 @@ func mapDiscussion(n discussionNode) Discussion { } if n.AnswerChosenBy != nil { - d.AnswerChosenBy = &DiscussionAuthor{Login: n.AnswerChosenBy.Login} + d.AnswerChosenBy = &DiscussionActor{ + ID: n.AnswerChosenBy.ID, + Login: n.AnswerChosenBy.Login, + Name: n.AnswerChosenBy.Name, + } } d.Labels = make([]DiscussionLabel, len(n.Labels.Nodes)) @@ -111,10 +123,10 @@ func mapDiscussion(n discussionNode) Discussion { // It is shared by both List (repository.discussions) and Search queries. const discussionFields = ` id number title body url closed stateReason - author { login } + author { login ...on User { id name } ...on Bot { id } } category { id name slug emoji isAnswerable } labels(first: 20) { nodes { id name color } } - isAnswered answerChosenAt answerChosenBy { login } + isAnswered answerChosenAt answerChosenBy { login ...on User { id name } ...on Bot { id } } reactionGroups { content users { totalCount } } createdAt updatedAt closedAt locked ` diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index d0fc0fadff8..1de2bae1a6c 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -12,12 +12,12 @@ type Discussion struct { URL string Closed bool StateReason string - Author DiscussionAuthor + Author DiscussionActor Category DiscussionCategory Labels []DiscussionLabel Answered bool AnswerChosenAt time.Time - AnswerChosenBy *DiscussionAuthor + AnswerChosenBy *DiscussionActor Comments DiscussionCommentList ReactionGroups []ReactionGroup CreatedAt time.Time @@ -103,15 +103,15 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { return data } -// DiscussionAuthor represents the author of a discussion or comment. -type DiscussionAuthor struct { +// 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 DiscussionAuthor) Export() map[string]interface{} { +func (a DiscussionActor) Export() map[string]interface{} { return map[string]interface{}{ "id": a.ID, "login": a.Login, @@ -159,7 +159,7 @@ func (l DiscussionLabel) Export() map[string]interface{} { type DiscussionComment struct { ID string URL string - Author DiscussionAuthor + Author DiscussionActor Body string CreatedAt time.Time IsAnswer bool diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go index 72cc92a891e..29df026949c 100644 --- a/pkg/cmd/discussion/list/list_test.go +++ b/pkg/cmd/discussion/list/list_test.go @@ -24,7 +24,7 @@ func sampleDiscussions() []client.Discussion { Number: 42, Title: "Bug report discussion", URL: "https://github.com/OWNER/REPO/discussions/42", - Author: client.DiscussionAuthor{Login: "monalisa"}, + Author: client.DiscussionActor{Login: "monalisa"}, Category: client.DiscussionCategory{ ID: "CAT1", Name: "General", @@ -40,7 +40,7 @@ func sampleDiscussions() []client.Discussion { Number: 41, Title: "Feature request", URL: "https://github.com/OWNER/REPO/discussions/41", - Author: client.DiscussionAuthor{Login: "octocat"}, + Author: client.DiscussionActor{Login: "octocat"}, Category: client.DiscussionCategory{ ID: "CAT2", Name: "Ideas", From e6befd5efdbfc009913dab9b5a4408a23940c77b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 12:16:23 +0100 Subject: [PATCH 11/81] fix(discussion/client): simplify list query Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 48 ++++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 27600922182..0e7b29f48a1 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -201,42 +201,32 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte variables["answered"] = *filters.Answered } - // Build optional parameter declarations - paramParts := []string{ - "$owner: String!", - "$name: String!", - "$first: Int!", - "$after: String", - "$orderBy: DiscussionOrder", - } - argParts := []string{ - "first: $first", - "after: $after", - "orderBy: $orderBy", - } - if filters.CategoryID != "" { - paramParts = append(paramParts, "$categoryId: ID") - argParts = append(argParts, "categoryId: $categoryId") - } - if _, ok := variables["states"]; ok { - paramParts = append(paramParts, "$states: [DiscussionState!]") - argParts = append(argParts, "states: $states") - } - if filters.Answered != nil { - paramParts = append(paramParts, "$answered: Boolean") - argParts = append(argParts, "answered: $answered") - } - - query := fmt.Sprintf(`query DiscussionList(%s) { + query := fmt.Sprintf(`query DiscussionList( + $owner: String!, + $name: String!, + $first: Int!, + $after: String, + $orderBy: DiscussionOrder, + $categoryId: ID, + $states: [DiscussionState!], + $answered: Boolean + ) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled - discussions(%s) { + discussions( + first: $first, + after: $after, + orderBy: $orderBy, + categoryId: $categoryId, + states: $states, + answered: $answered + ) { totalCount pageInfo { hasNextPage endCursor } nodes { %s } } } - }`, strings.Join(paramParts, ", "), strings.Join(argParts, ", "), discussionFields) + }`, discussionFields) if after != "" { variables["after"] = after From 2a46a9d733cc29740bcff1c107feb0854589a077 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 12:24:08 +0100 Subject: [PATCH 12/81] fix(discussion/client): change list return type to pointer Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client.go | 4 +-- pkg/cmd/discussion/client/client_impl.go | 30 +++++++++---------- pkg/cmd/discussion/client/client_mock.go | 12 ++++---- pkg/cmd/discussion/list/list.go | 2 +- pkg/cmd/discussion/list/list_test.go | 38 ++++++++++++------------ 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index 7f5fdfd4b20..a794d13e13c 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -9,8 +9,8 @@ import "github.com/cli/cli/v2/internal/ghrepo" // 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) + 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, order string) (*Discussion, error) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 0e7b29f48a1..9d22454d731 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -131,9 +131,9 @@ const discussionFields = ` createdAt updatedAt closedAt locked ` -func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) { +func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) { if limit <= 0 { - return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit) + return nil, fmt.Errorf("limit argument must be positive: %v", limit) } type response struct { @@ -164,7 +164,7 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte case OrderByUpdated: orderField = "UPDATED_AT" default: - return DiscussionListResult{}, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) + return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) } } if filters.Direction != "" { @@ -174,7 +174,7 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte case OrderDirectionDesc: orderDir = "DESC" default: - return DiscussionListResult{}, fmt.Errorf("unknown order direction: %q", filters.Direction) + return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } } variables["orderBy"] = map[string]string{ @@ -193,7 +193,7 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte case FilterStateClosed: variables["states"] = []string{"CLOSED"} default: - return DiscussionListResult{}, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) + return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) } } @@ -249,11 +249,11 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte var data response if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { - return DiscussionListResult{}, err + return nil, err } if firstPage && !data.Repository.HasDiscussionsEnabled { - return DiscussionListResult{}, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } firstPage = false @@ -272,16 +272,16 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte variables["after"] = data.Repository.Discussions.PageInfo.EndCursor } - return DiscussionListResult{ + return &DiscussionListResult{ Discussions: discussions, TotalCount: totalCount, NextCursor: nextCursor, }, nil } -func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (DiscussionListResult, error) { +func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) { if limit <= 0 { - return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit) + return nil, fmt.Errorf("limit argument must be positive: %v", limit) } type response struct { @@ -304,7 +304,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, case FilterStateClosed: qualifiers = append(qualifiers, "state:closed") default: - return DiscussionListResult{}, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) + return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) } } @@ -332,7 +332,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, case OrderByCreated, OrderByUpdated: orderField = filters.OrderBy default: - return DiscussionListResult{}, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) + return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) } } if filters.Direction != "" { @@ -340,7 +340,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, case OrderDirectionAsc, OrderDirectionDesc: orderDir = filters.Direction default: - return DiscussionListResult{}, fmt.Errorf("unknown order direction: %q", filters.Direction) + return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } } qualifiers = append(qualifiers, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) @@ -380,7 +380,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, var data response if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { - return DiscussionListResult{}, err + return nil, err } totalCount = data.Search.DiscussionCount @@ -398,7 +398,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, variables["after"] = data.Search.PageInfo.EndCursor } - return DiscussionListResult{ + return &DiscussionListResult{ Discussions: discussions, TotalCount: totalCount, NextCursor: nextCursor, diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index 589258b4ff7..a690f84b135 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -33,7 +33,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { // panic("mock out the GetWithComments method") // }, -// ListFunc: func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) { +// 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) { @@ -48,7 +48,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // 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) { +// 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 { @@ -83,7 +83,7 @@ type DiscussionClientMock struct { GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) // ListFunc mocks the List method. - ListFunc func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) + 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) @@ -98,7 +98,7 @@ type DiscussionClientMock struct { 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) + 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 @@ -445,7 +445,7 @@ func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct { } // List calls ListFunc. -func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) { +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") } @@ -633,7 +633,7 @@ func (mock *DiscussionClientMock) ReopenCalls() []struct { } // Search calls SearchFunc. -func (mock *DiscussionClientMock) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (DiscussionListResult, error) { +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") } diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 119d3ff9d6d..f4305b9269e 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -181,7 +181,7 @@ func listRun(opts *ListOptions) error { state := toFilterState(opts.State) - var result client.DiscussionListResult + var result *client.DiscussionListResult useSearch := opts.Author != "" || len(opts.Labels) > 0 || opts.Search != "" if useSearch { diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go index 29df026949c..cb43d561c8d 100644 --- a/pkg/cmd/discussion/list/list_test.go +++ b/pkg/cmd/discussion/list/list_test.go @@ -53,8 +53,8 @@ func sampleDiscussions() []client.Discussion { } } -func sampleResult() client.DiscussionListResult { - return client.DiscussionListResult{ +func sampleResult() *client.DiscussionListResult { + return &client.DiscussionListResult{ Discussions: sampleDiscussions(), TotalCount: 2, } @@ -74,7 +74,7 @@ func TestListRun_tty(t *testing.T) { ios.SetStderrTTY(true) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { return sampleResult(), nil }, } @@ -109,7 +109,7 @@ func TestListRun_nontty(t *testing.T) { ios.SetStdoutTTY(false) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { return sampleResult(), nil }, } @@ -139,8 +139,8 @@ 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{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{ Discussions: sampleDiscussions(), TotalCount: 2, NextCursor: "CURSOR123", @@ -199,8 +199,8 @@ func TestListRun_noResults(t *testing.T) { 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 + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{}, nil }, } @@ -229,9 +229,9 @@ func TestListRun_categoryFilter(t *testing.T) { 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) { + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { assert.Equal(t, "CAT1", filters.CategoryID) - return client.DiscussionListResult{ + return &client.DiscussionListResult{ Discussions: sampleDiscussions()[:1], TotalCount: 1, }, nil @@ -286,9 +286,9 @@ func TestListRun_authorFilter(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (client.DiscussionListResult, error) { + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (*client.DiscussionListResult, error) { assert.Equal(t, "monalisa", filters.Author) - return client.DiscussionListResult{ + return &client.DiscussionListResult{ Discussions: sampleDiscussions()[:1], TotalCount: 1, }, nil @@ -317,9 +317,9 @@ func TestListRun_labelFilter(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (client.DiscussionListResult, error) { + 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{ + return &client.DiscussionListResult{ Discussions: sampleDiscussions()[:1], TotalCount: 1, }, nil @@ -348,9 +348,9 @@ func TestListRun_searchFilter(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, after string, limit int) (client.DiscussionListResult, error) { + 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{ + return &client.DiscussionListResult{ Discussions: sampleDiscussions()[:1], TotalCount: 1, }, nil @@ -379,7 +379,7 @@ func TestListRun_afterCursor(t *testing.T) { ios.SetStdoutTTY(true) mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { assert.Equal(t, "CURSOR_ABC", after) return sampleResult(), nil }, @@ -582,8 +582,8 @@ func TestListRun_closedState(t *testing.T) { } mockClient := &client.DiscussionClientMock{ - ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (client.DiscussionListResult, error) { - return client.DiscussionListResult{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, after string, limit int) (*client.DiscussionListResult, error) { + return &client.DiscussionListResult{ Discussions: closed, TotalCount: 1, }, nil From 9afbd61c5f14c75cdf7124c3f48aba6819cc4f9a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 13:25:42 +0100 Subject: [PATCH 13/81] refactor(discussion/client): use strongly-typed query for search function Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 203 +++++++++++++++++------ 1 file changed, 153 insertions(+), 50 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 9d22454d731..86d14276224 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -22,7 +22,7 @@ func NewDiscussionClient(httpClient *http.Client) DiscussionClient { } } -// discussionNode is the shared GraphQL response shape for a single discussion, +// discussionNode is the GraphQL response shape for a single discussion, // used by both List and Search to avoid duplicating the field mapping. type discussionNode struct { ID string `json:"id"` @@ -69,6 +69,74 @@ type discussionNode struct { Locked bool `json:"locked"` } +// 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 + } + } + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt time.Time + Locked bool +} + // mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. func mapDiscussion(n discussionNode) Discussion { d := Discussion{ @@ -119,6 +187,50 @@ func mapDiscussion(n discussionNode) Discussion { return d } +// 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} + } + + d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups)) + for i, rg := range n.ReactionGroups { + d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} + } + + return d +} + // discussionFields is the GraphQL fragment selecting fields for discussion queries. // It is shared by both List (repository.discussions) and Search queries. const discussionFields = ` @@ -284,15 +396,17 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, return nil, fmt.Errorf("limit argument must be positive: %v", limit) } - type response struct { + var query struct { Search struct { - DiscussionCount int `json:"discussionCount"` + DiscussionCount int PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - Nodes []discussionNode `json:"nodes"` - } `json:"search"` + 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())} @@ -300,9 +414,9 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, if filters.State != nil { switch *filters.State { case FilterStateOpen: - qualifiers = append(qualifiers, "state:open") + qualifiers = append(qualifiers, "is:open") case FilterStateClosed: - qualifiers = append(qualifiers, "state:closed") + qualifiers = append(qualifiers, "is:closed") default: return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed) } @@ -325,20 +439,24 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, } } - orderField := OrderByUpdated - orderDir := OrderDirectionDesc + orderField := "updated" + orderDir := "desc" if filters.OrderBy != "" { switch filters.OrderBy { - case OrderByCreated, OrderByUpdated: - orderField = 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, OrderDirectionDesc: - orderDir = filters.Direction + case OrderDirectionAsc: + orderDir = "asc" + case OrderDirectionDesc: + orderDir = "desc" default: return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } @@ -350,59 +468,44 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, searchQuery += " " + filters.Keywords } - query := fmt.Sprintf(`query DiscussionSearch($query: String!, $first: Int!, $after: String) { - search(query: $query, type: DISCUSSION, first: $first, after: $after) { - discussionCount - pageInfo { hasNextPage endCursor } - nodes { ... on Discussion { %s } } - } - }`, discussionFields) + perPage := limit + if perPage > 100 { + perPage = 100 + } variables := map[string]interface{}{ - "query": searchQuery, + "query": githubv4.String(searchQuery), + "first": githubv4.Int(perPage), + "after": (*githubv4.String)(nil), } - if after != "" { - variables["after"] = after + variables["after"] = githubv4.String(after) } - var discussions []Discussion - var totalCount int - var nextCursor string + var result DiscussionListResult remaining := limit for { - perPage := remaining - if perPage > 100 { - perPage = 100 - } - variables["first"] = perPage - - var data response - if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + if err := c.gql.Query(repo.RepoHost(), "DiscussionListSearch", &query, variables); err != nil { return nil, err } - totalCount = data.Search.DiscussionCount - for _, n := range data.Search.Nodes { - discussions = append(discussions, mapDiscussion(n)) + result.TotalCount = query.Search.DiscussionCount + for _, n := range query.Search.Nodes { + result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n.Discussion)) } - remaining -= len(data.Search.Nodes) - if remaining <= 0 || !data.Search.PageInfo.HasNextPage { - if data.Search.PageInfo.HasNextPage { - nextCursor = data.Search.PageInfo.EndCursor + 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"] = data.Search.PageInfo.EndCursor + variables["after"] = githubv4.String(query.Search.PageInfo.EndCursor) } - return &DiscussionListResult{ - Discussions: discussions, - TotalCount: totalCount, - NextCursor: nextCursor, - }, nil + return &result, nil } func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { From e403e82633045baa3d19c00b1b4d24f0d8e6eb53 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 14:30:21 +0100 Subject: [PATCH 14/81] refactor(discussion/client): use strongly-typed query for list function Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 247 +++++------------------ 1 file changed, 51 insertions(+), 196 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 86d14276224..265b0a481ca 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -22,53 +22,6 @@ func NewDiscussionClient(httpClient *http.Client) DiscussionClient { } } -// discussionNode is the GraphQL response shape for a single discussion, -// used by both List and Search to avoid duplicating the field mapping. -type discussionNode struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - Closed bool `json:"closed"` - StateReason string `json:"stateReason"` - Author struct { - Login string `json:"login"` - ID string `json:"id"` - Name string `json:"name"` - } `json:"author"` - Category struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Emoji string `json:"emoji"` - IsAnswerable bool `json:"isAnswerable"` - } `json:"category"` - Labels struct { - Nodes []struct { - ID string `json:"id"` - Name string `json:"name"` - Color string `json:"color"` - } `json:"nodes"` - } `json:"labels"` - IsAnswered bool `json:"isAnswered"` - AnswerChosenAt time.Time `json:"answerChosenAt"` - AnswerChosenBy *struct { - Login string `json:"login"` - ID string `json:"id"` - Name string `json:"name"` - } `json:"answerChosenBy"` - ReactionGroups []struct { - Content string `json:"content"` - Users struct { - TotalCount int `json:"totalCount"` - } `json:"users"` - } `json:"reactionGroups"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - ClosedAt time.Time `json:"closedAt"` - Locked bool `json:"locked"` -} - // actorNode is the GraphQL response shape for an Actor union (User or Bot) // used in discussionListNode fields like Author and AnswerChosenBy. type actorNode struct { @@ -137,56 +90,6 @@ type discussionListNode struct { Locked bool } -// mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. -func mapDiscussion(n discussionNode) Discussion { - d := Discussion{ - ID: n.ID, - Number: n.Number, - Title: n.Title, - URL: n.URL, - Closed: n.Closed, - StateReason: n.StateReason, - Author: DiscussionActor{ - ID: n.Author.ID, - Login: n.Author.Login, - Name: n.Author.Name, - }, - 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 { - d.AnswerChosenBy = &DiscussionActor{ - ID: n.AnswerChosenBy.ID, - Login: n.AnswerChosenBy.Login, - Name: n.AnswerChosenBy.Name, - } - } - - 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} - } - - d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups)) - for i, rg := range n.ReactionGroups { - d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} - } - - return d -} - // mapDiscussionFromListNode converts a discussionListNode into the domain Discussion type. func mapDiscussionFromListNode(n discussionListNode) Discussion { d := Discussion{ @@ -231,50 +134,33 @@ func mapDiscussionFromListNode(n discussionListNode) Discussion { return d } -// discussionFields is the GraphQL fragment selecting fields for discussion queries. -// It is shared by both List (repository.discussions) and Search queries. -const discussionFields = ` - id number title body url closed stateReason - author { login ...on User { id name } ...on Bot { id } } - category { id name slug emoji isAnswerable } - labels(first: 20) { nodes { id name color } } - isAnswered answerChosenAt answerChosenBy { login ...on User { id name } ...on Bot { id } } - reactionGroups { content users { totalCount } } - createdAt updatedAt closedAt locked -` - 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) } - type response struct { + var query struct { Repository struct { - HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + HasDiscussionsEnabled bool Discussions struct { - TotalCount int `json:"totalCount"` + TotalCount int PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - Nodes []discussionNode `json:"nodes"` - } `json:"discussions"` - } `json:"repository"` - } - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "name": repo.RepoName(), + 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 := "UPDATED_AT" - orderDir := "DESC" + orderField := githubv4.DiscussionOrderFieldUpdatedAt + orderDir := githubv4.OrderDirectionDesc if filters.OrderBy != "" { switch filters.OrderBy { case OrderByCreated: - orderField = "CREATED_AT" + orderField = githubv4.DiscussionOrderFieldCreatedAt case OrderByUpdated: - orderField = "UPDATED_AT" + orderField = githubv4.DiscussionOrderFieldUpdatedAt default: return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy) } @@ -282,113 +168,82 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte if filters.Direction != "" { switch filters.Direction { case OrderDirectionAsc: - orderDir = "ASC" + orderDir = githubv4.OrderDirectionAsc case OrderDirectionDesc: - orderDir = "DESC" + orderDir = githubv4.OrderDirectionDesc default: return nil, fmt.Errorf("unknown order direction: %q", filters.Direction) } } - variables["orderBy"] = map[string]string{ - "field": orderField, - "direction": orderDir, + + perPage := limit + if perPage > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "first": githubv4.Int(perPage), + "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"] = filters.CategoryID + variables["categoryId"] = githubv4.ID(filters.CategoryID) } if filters.State != nil { switch *filters.State { case FilterStateOpen: - variables["states"] = []string{"OPEN"} + variables["states"] = &[]githubv4.DiscussionState{githubv4.DiscussionStateOpen} case FilterStateClosed: - variables["states"] = []string{"CLOSED"} + 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"] = *filters.Answered - } - - query := fmt.Sprintf(`query DiscussionList( - $owner: String!, - $name: String!, - $first: Int!, - $after: String, - $orderBy: DiscussionOrder, - $categoryId: ID, - $states: [DiscussionState!], - $answered: Boolean - ) { - repository(owner: $owner, name: $name) { - hasDiscussionsEnabled - discussions( - first: $first, - after: $after, - orderBy: $orderBy, - categoryId: $categoryId, - states: $states, - answered: $answered - ) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { %s } - } - } - }`, discussionFields) - - if after != "" { - variables["after"] = after + variables["answered"] = githubv4.Boolean(*filters.Answered) } - var discussions []Discussion - var totalCount int - var nextCursor string + var result DiscussionListResult remaining := limit - // Check hasDiscussionsEnabled on first request only - firstPage := true - for { - perPage := remaining - if perPage > 100 { - perPage = 100 - } - variables["first"] = perPage - - var data response - if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + if err := c.gql.Query(repo.RepoHost(), "DiscussionList", &query, variables); err != nil { return nil, err } - if firstPage && !data.Repository.HasDiscussionsEnabled { + 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()) } - firstPage = false - totalCount = data.Repository.Discussions.TotalCount - for _, n := range data.Repository.Discussions.Nodes { - discussions = append(discussions, mapDiscussion(n)) + result.TotalCount = query.Repository.Discussions.TotalCount + for _, n := range query.Repository.Discussions.Nodes { + result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n)) } - remaining -= len(data.Repository.Discussions.Nodes) - if remaining <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage { - if data.Repository.Discussions.PageInfo.HasNextPage { - nextCursor = data.Repository.Discussions.PageInfo.EndCursor + 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"] = data.Repository.Discussions.PageInfo.EndCursor + variables["after"] = githubv4.String(query.Repository.Discussions.PageInfo.EndCursor) } - return &DiscussionListResult{ - Discussions: discussions, - TotalCount: totalCount, - NextCursor: nextCursor, - }, nil + return &result, nil } func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) { From afb1b7dfea7c60d9827986c69b8cac60d7e6716f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 14:36:21 +0100 Subject: [PATCH 15/81] fix(discussion/shared): print quoted category slugs Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/categories.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/shared/categories.go b/pkg/cmd/discussion/shared/categories.go index a7893f229db..5e0ca33cdc1 100644 --- a/pkg/cmd/discussion/shared/categories.go +++ b/pkg/cmd/discussion/shared/categories.go @@ -28,5 +28,5 @@ func MatchCategory(input string, categories []client.DiscussionCategory) (*clien slugs[i] = c.Slug } slices.Sort(slugs) - return nil, fmt.Errorf("unknown category: %q; must be one of %v", input, slugs) + return nil, fmt.Errorf("unknown category: %q; must be one of %q", input, slugs) } From 9a42e904a5eae6c465ab0f6bed6bc0bc97ef55ac Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 14:37:25 +0100 Subject: [PATCH 16/81] fix(discussion/shared): deleted unused fields slice Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/fields.go | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 pkg/cmd/discussion/shared/fields.go diff --git a/pkg/cmd/discussion/shared/fields.go b/pkg/cmd/discussion/shared/fields.go deleted file mode 100644 index 0feb789d9f2..00000000000 --- a/pkg/cmd/discussion/shared/fields.go +++ /dev/null @@ -1,25 +0,0 @@ -package shared - -// DiscussionFields lists the field names available for --json output on -// discussion commands. -var DiscussionFields = []string{ - "id", - "number", - "title", - "body", - "url", - "closed", - "stateReason", - "author", - "category", - "labels", - "answered", - "answerChosenAt", - "answerChosenBy", - "comments", - "reactionGroups", - "createdAt", - "updatedAt", - "closedAt", - "locked", -} From 835d7f3a4875037d17f9cafa838418f80d630f2f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 14:50:40 +0100 Subject: [PATCH 17/81] fix(discussion/client): remove reaction groups from list/search types Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 7 +------ pkg/cmd/discussion/client/types.go | 7 ------- pkg/cmd/discussion/list/list.go | 1 - 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 265b0a481ca..ed44e49bec2 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -83,7 +83,7 @@ type discussionListNode struct { Users struct { TotalCount int } - } + } `graphql:"reactionGroups"` CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time @@ -126,11 +126,6 @@ func mapDiscussionFromListNode(n discussionListNode) Discussion { d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} } - d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups)) - for i, rg := range n.ReactionGroups { - d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} - } - return d } diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 1de2bae1a6c..f3c58456c4d 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -19,7 +19,6 @@ type Discussion struct { AnswerChosenAt time.Time AnswerChosenBy *DiscussionActor Comments DiscussionCommentList - ReactionGroups []ReactionGroup CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time @@ -80,12 +79,6 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { "totalCount": d.Comments.TotalCount, "nodes": comments, } - case "reactionGroups": - groups := make([]interface{}, len(d.ReactionGroups)) - for i, rg := range d.ReactionGroups { - groups[i] = rg.Export() - } - data[f] = groups case "createdAt": data[f] = d.CreatedAt case "updatedAt": diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index f4305b9269e..77ac2a7ef8a 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -37,7 +37,6 @@ var discussionListFields = []string{ "answered", "answerChosenAt", "answerChosenBy", - "reactionGroups", "createdAt", "updatedAt", "closedAt", From 2d1b1a79bf76e8cd9a9b3154b4c1512ade1aaade Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 14:52:05 +0100 Subject: [PATCH 18/81] fix(discussion list): only print remaining items in tty mode Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 77ac2a7ef8a..0fe2c448801 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -346,8 +346,7 @@ func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalC _ = tp.Render() - remaining := totalCount - len(discussions) - if remaining > 0 { + if remaining := totalCount - len(discussions); isTerminal && remaining > 0 { fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining) } } From d45ce940eb2e8d0428f5adcb0643b2066fbb8c37 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 15:00:17 +0100 Subject: [PATCH 19/81] refactor(discussion list): inline printed messages Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 0fe2c448801..76ef86b32ac 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -273,25 +273,25 @@ func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error { } func noResults(repo ghrepo.Interface, state string) error { - stateQualifier := "" switch state { case "open": - stateQualifier = " open" + return cmdutil.NewNoResultsError(fmt.Sprintf("no open discussions match your search in %s", ghrepo.FullName(repo))) case "closed": - stateQualifier = " 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))) } - return cmdutil.NewNoResultsError(fmt.Sprintf("no%s discussions match your search in %s", stateQualifier, ghrepo.FullName(repo))) } func listHeader(repoName string, count, total int, state string) string { - stateQualifier := "" switch state { case "open": - stateQualifier = " open" + return fmt.Sprintf("Showing %d of %d open discussions in %s", count, total, repoName) case "closed": - stateQualifier = " 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) } - return fmt.Sprintf("Showing %d of %d%s discussions in %s", count, total, stateQualifier, repoName) } func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalCount int) { From 8d70cc9ca8be3031a2c339af71638c65d5d665bf Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 15:03:02 +0100 Subject: [PATCH 20/81] refactor(discussion list): encapsulate printing within `printDiscussions` Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 76ef86b32ac..48c53380b5a 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -228,13 +228,7 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } - isTerminal := opts.IO.IsStdoutTTY() - if isTerminal { - title := listHeader(ghrepo.FullName(repo), len(result.Discussions), result.TotalCount, opts.State) - fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) - } - - printDiscussions(opts, result.Discussions, result.TotalCount) + printDiscussions(opts, ghrepo.FullName(repo), result.Discussions, result.TotalCount) return nil } @@ -294,11 +288,16 @@ func listHeader(repoName string, count, total int, state string) string { } } -func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalCount int) { +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"} @@ -309,7 +308,7 @@ func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalC if isTerminal { idColor := cs.Green if d.Closed { - idColor = cs.Gray + idColor = cs.Muted } tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor)) } else { From f62875fb63dcbd97c6444fbefa853923654dc02b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 15:05:22 +0100 Subject: [PATCH 21/81] fix(discussion list): quote author qualifier in web mode Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 48c53380b5a..26114cf5e4a 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -240,7 +240,7 @@ func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error { queryParts = append(queryParts, "is:"+opts.State) } if opts.Author != "" { - queryParts = append(queryParts, "author:"+opts.Author) + queryParts = append(queryParts, fmt.Sprintf("author:%q", opts.Author)) } for _, l := range opts.Labels { queryParts = append(queryParts, fmt.Sprintf("label:%q", l)) From 2a4a982ae436e3bce92f9f6a8902c2a6cd71645c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 14 Apr 2026 15:07:32 +0100 Subject: [PATCH 22/81] fix(discussion list): include search keywords in web mode Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/list/list.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go index 26114cf5e4a..3de5df6c6b6 100644 --- a/pkg/cmd/discussion/list/list.go +++ b/pkg/cmd/discussion/list/list.go @@ -236,6 +236,9 @@ 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) } From 35e8cc93cfc85ad5e710a995cca2a041b582e1f2 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Tue, 21 Apr 2026 09:12:31 -0500 Subject: [PATCH 23/81] test(discussion): add httpmock unit tests for DiscussionClient Add comprehensive httpmock-based unit tests for the client package covering: - List: success path, discussions disabled, limit/filter validation, pagination - Search: success path, filter validation, pagination - ListCategories: success path, discussions disabled Tests use httpmock.Registry with defer Verify(t) to ensure all stubs are exercised, following the established testing pattern in this repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 pkg/cmd/discussion/client/client_impl_test.go 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..bcc678b5c51 --- /dev/null +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -0,0 +1,504 @@ +package client + +import ( + "net/http" + "testing" + + "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) +} + +func ptr[T any](v T) *T { return &v } + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +func TestList_success(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": true, + "discussions": { + "totalCount": 1, + "pageInfo": {"hasNextPage": false, "endCursor": ""}, + "nodes": [{ + "id": "D_id1", + "number": 1, + "title": "Hello world", + "body": "body text", + "url": "https://github.com/OWNER/REPO/discussions/1", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + }] + } + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 10) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.TotalCount) + assert.Len(t, result.Discussions, 1) + assert.Equal(t, "Hello world", result.Discussions[0].Title) + assert.Equal(t, "", result.NextCursor) +} + +func TestList_discussionsDisabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": false, + "discussions": {"totalCount": 0, "pageInfo": {"hasNextPage": false, "endCursor": ""}, "nodes": []} + }}}`), + ) + + c := newTestDiscussionClient(reg) + _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "discussions disabled") +} + +func TestList_limitZero(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "limit argument must be positive") +} + +func TestList_invalidOrderBy(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{OrderBy: "invalid"}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown order-by field") +} + +func TestList_invalidDirection(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{Direction: "sideways"}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown order direction") +} + +func TestList_invalidState(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{State: ptr("merged")}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown state filter") +} + +func TestList_pagination(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // First page + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": true, + "discussions": { + "totalCount": 2, + "pageInfo": {"hasNextPage": true, "endCursor": "cursor1"}, + "nodes": [{ + "id": "D1", "number": 1, "title": "Discussion 1", "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 + }] + } + }}}`), + ) + // Second page + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": true, + "discussions": { + "totalCount": 2, + "pageInfo": {"hasNextPage": false, "endCursor": ""}, + "nodes": [{ + "id": "D2", "number": 2, "title": "Discussion 2", "body": "", + "url": "", "closed": false, "stateReason": "", "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "bob"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, "labels": {"nodes": []}, "reactionGroups": [], + "createdAt": "2024-01-02T00:00:00Z", "updatedAt": "2024-01-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", "locked": false + }] + } + }}}`), + ) + + c := newTestDiscussionClient(reg) + // limit > 1 forces pagination across both pages + result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 2) + require.NoError(t, err) + assert.Len(t, result.Discussions, 2) + assert.Equal(t, "Discussion 1", result.Discussions[0].Title) + assert.Equal(t, "Discussion 2", result.Discussions[1].Title) + assert.Equal(t, "", result.NextCursor) +} + +func TestList_paginationSetsNextCursor(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // When the caller requests fewer items than are available, NextCursor should be set. + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": true, + "discussions": { + "totalCount": 5, + "pageInfo": {"hasNextPage": true, "endCursor": "cursor42"}, + "nodes": [{ + "id": "D1", "number": 1, "title": "Discussion 1", "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 + }] + } + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 1) + require.NoError(t, err) + assert.Len(t, result.Discussions, 1) + assert.Equal(t, "cursor42", result.NextCursor) +} + +func TestList_filters(t *testing.T) { + tests := []struct { + name string + filters ListFilters + }{ + { + name: "open state", + filters: ListFilters{State: ptr(FilterStateOpen)}, + }, + { + name: "closed state", + filters: ListFilters{State: ptr(FilterStateClosed)}, + }, + { + name: "answered", + filters: ListFilters{Answered: ptr(true)}, + }, + { + name: "unanswered", + filters: ListFilters{Answered: ptr(false)}, + }, + { + name: "category ID", + filters: ListFilters{CategoryID: "CAT123"}, + }, + { + name: "order by created asc", + filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + }, + { + name: "order by updated desc", + filters: ListFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": true, + "discussions": {"totalCount": 0, "pageInfo": {"hasNextPage": false, "endCursor": ""}, "nodes": []} + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.List(ghrepo.New("OWNER", "REPO"), tt.filters, "", 10) + require.NoError(t, err) + assert.NotNil(t, result) + }) + } +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +func TestSearch_success(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(`{"data":{"search":{ + "discussionCount": 1, + "pageInfo": {"hasNextPage": false, "endCursor": ""}, + "nodes": [{ + "id": "D1", "number": 1, "title": "Searched discussion", "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 + }] + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 10) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.TotalCount) + assert.Len(t, result.Discussions, 1) + assert.Equal(t, "Searched discussion", result.Discussions[0].Title) +} + +func TestSearch_limitZero(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "limit argument must be positive") +} + +func TestSearch_invalidOrderBy(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{OrderBy: "bogus"}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown order-by field") +} + +func TestSearch_invalidDirection(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{Direction: "sideways"}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown order direction") +} + +func TestSearch_invalidState(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + c := newTestDiscussionClient(reg) + _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{State: ptr("merged")}, "", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown state filter") +} + +func TestSearch_filters(t *testing.T) { + tests := []struct { + name string + filters SearchFilters + }{ + { + name: "open state", + filters: SearchFilters{State: ptr(FilterStateOpen)}, + }, + { + name: "closed state", + filters: SearchFilters{State: ptr(FilterStateClosed)}, + }, + { + name: "answered", + filters: SearchFilters{Answered: ptr(true)}, + }, + { + name: "unanswered", + filters: SearchFilters{Answered: ptr(false)}, + }, + { + name: "author", + filters: SearchFilters{Author: "alice"}, + }, + { + name: "labels", + filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, + }, + { + name: "category", + filters: SearchFilters{Category: "Q&A"}, + }, + { + name: "keywords", + filters: SearchFilters{Keywords: "some keyword"}, + }, + { + name: "order by created asc", + filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + }, + { + name: "order by updated desc", + filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(`{"data":{"search":{ + "discussionCount": 0, + "pageInfo": {"hasNextPage": false, "endCursor": ""}, + "nodes": [] + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.Search(ghrepo.New("OWNER", "REPO"), tt.filters, "", 10) + require.NoError(t, err) + assert.NotNil(t, result) + }) + } +} + +func TestSearch_pagination(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + makeNode := func(id, title string) string { + return `{ + "id": "` + id + `", "number": 1, "title": "` + title + `", "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 + }` + } + + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(`{"data":{"search":{ + "discussionCount": 2, + "pageInfo": {"hasNextPage": true, "endCursor": "searchCursor1"}, + "nodes": [`+makeNode("D1", "First")+`] + }}}`), + ) + reg.Register( + httpmock.GraphQL(`query DiscussionListSearch\b`), + httpmock.StringResponse(`{"data":{"search":{ + "discussionCount": 2, + "pageInfo": {"hasNextPage": false, "endCursor": ""}, + "nodes": [`+makeNode("D2", "Second")+`] + }}}`), + ) + + c := newTestDiscussionClient(reg) + result, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 2) + require.NoError(t, err) + assert.Len(t, result.Discussions, 2) + assert.Equal(t, "First", result.Discussions[0].Title) + assert.Equal(t, "Second", result.Discussions[1].Title) +} + +// --------------------------------------------------------------------------- +// ListCategories +// --------------------------------------------------------------------------- + +func TestListCategories_success(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + 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} + ]} + }}}`), + ) + + c := newTestDiscussionClient(reg) + categories, err := c.ListCategories(ghrepo.New("OWNER", "REPO")) + require.NoError(t, err) + require.Len(t, categories, 2) + assert.Equal(t, "General", categories[0].Name) + assert.Equal(t, "Q&A", categories[1].Name) + assert.True(t, categories[1].IsAnswerable) +} + +func TestListCategories_discussionsDisabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query DiscussionCategoryList\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasDiscussionsEnabled": false, + "discussionCategories": {"nodes": []} + }}}`), + ) + + c := newTestDiscussionClient(reg) + _, err := c.ListCategories(ghrepo.New("OWNER", "REPO")) + require.Error(t, err) + assert.Contains(t, err.Error(), "discussions disabled") +} From aa080ad28aa74425c45901fd8d2c729bb7a4f7a0 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 22 Apr 2026 15:04:59 -0500 Subject: [PATCH 24/81] refactor(discussion): convert client tests to table-driven Addresses babakks' review on PR #13252: - Convert 18 individual test functions to 3 table-driven functions - Replace ptr helper with new(value) syntax throughout - Assert all Discussion struct fields in success cases - Add after-cursor test cases for pagination - Fold pagination tests into table structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 831 +++++++++--------- 1 file changed, 432 insertions(+), 399 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index bcc678b5c51..cb4a89f62ff 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -3,6 +3,7 @@ package client import ( "net/http" "testing" + "time" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" @@ -16,237 +17,214 @@ func newTestDiscussionClient(reg *httpmock.Registry) DiscussionClient { return NewDiscussionClient(httpClient) } -func ptr[T any](v T) *T { return &v } - -// --------------------------------------------------------------------------- -// List -// --------------------------------------------------------------------------- - -func TestList_success(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": true, - "discussions": { - "totalCount": 1, - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "D_id1", - "number": 1, - "title": "Hello world", - "body": "body text", - "url": "https://github.com/OWNER/REPO/discussions/1", - "closed": false, - "stateReason": "", - "isAnswered": false, - "answerChosenAt": "0001-01-01T00:00:00Z", - "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, - "category": {"id": "C1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, - "answerChosenBy": null, - "labels": {"nodes": []}, - "reactionGroups": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-02T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", - "locked": false - }] - } - }}}`), - ) - - c := newTestDiscussionClient(reg) - result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 10) - require.NoError(t, err) - require.NotNil(t, result) - assert.Equal(t, 1, result.TotalCount) - assert.Len(t, result.Discussions, 1) - assert.Equal(t, "Hello world", result.Discussions[0].Title) - assert.Equal(t, "", result.NextCursor) -} - -func TestList_discussionsDisabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": false, - "discussions": {"totalCount": 0, "pageInfo": {"hasNextPage": false, "endCursor": ""}, "nodes": []} - }}}`), - ) - - c := newTestDiscussionClient(reg) - _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "discussions disabled") +// minimalNode returns a minimal JSON discussion node with the given id and title. +func minimalNode(id, title string) string { + return `{"id":"` + id + `","number":1,"title":"` + title + `","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}` } -func TestList_limitZero(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 0) - require.Error(t, err) - assert.Contains(t, err.Error(), "limit argument must be positive") -} - -func TestList_invalidOrderBy(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{OrderBy: "invalid"}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown order-by field") +// listResp builds a mock repository.discussions JSON response. +func listResp(hasNext bool, cursor string, total int, nodes string) string { + hasNextStr := "false" + if hasNext { + hasNextStr = "true" + } + return `{"data":{"repository":{"hasDiscussionsEnabled":true,"discussions":{"totalCount":` + + intStr(total) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}}` } -func TestList_invalidDirection(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{Direction: "sideways"}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown order direction") +// searchResp builds a mock search JSON response. +func searchResp(hasNext bool, cursor string, count int, nodes string) string { + hasNextStr := "false" + if hasNext { + hasNextStr = "true" + } + return `{"data":{"search":{"discussionCount":` + + intStr(count) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}` } -func TestList_invalidState(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{State: ptr("merged")}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown state filter") +// intStr converts an int to its decimal string representation without importing strconv. +func intStr(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := [20]byte{} + pos := len(buf) + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) } -func TestList_pagination(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - // First page - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": true, - "discussions": { - "totalCount": 2, - "pageInfo": {"hasNextPage": true, "endCursor": "cursor1"}, - "nodes": [{ - "id": "D1", "number": 1, "title": "Discussion 1", "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 - }] - } - }}}`), - ) - // Second page - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": true, - "discussions": { - "totalCount": 2, - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "D2", "number": 2, "title": "Discussion 2", "body": "", - "url": "", "closed": false, "stateReason": "", "isAnswered": false, - "answerChosenAt": "0001-01-01T00:00:00Z", - "author": {"__typename": "User", "login": "bob"}, - "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, - "answerChosenBy": null, "labels": {"nodes": []}, "reactionGroups": [], - "createdAt": "2024-01-02T00:00:00Z", "updatedAt": "2024-01-02T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", "locked": false - }] - } - }}}`), - ) - - c := newTestDiscussionClient(reg) - // limit > 1 forces pagination across both pages - result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 2) - require.NoError(t, err) - assert.Len(t, result.Discussions, 2) - assert.Equal(t, "Discussion 1", result.Discussions[0].Title) - assert.Equal(t, "Discussion 2", result.Discussions[1].Title) - assert.Equal(t, "", result.NextCursor) -} +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- -func TestList_paginationSetsNextCursor(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - // When the caller requests fewer items than are available, NextCursor should be set. - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": true, - "discussions": { - "totalCount": 5, - "pageInfo": {"hasNextPage": true, "endCursor": "cursor42"}, - "nodes": [{ - "id": "D1", "number": 1, "title": "Discussion 1", "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 - }] - } - }}}`), - ) - - c := newTestDiscussionClient(reg) - result, err := c.List(ghrepo.New("OWNER", "REPO"), ListFilters{}, "", 1) - require.NoError(t, err) - assert.Len(t, result.Discussions, 1) - assert.Equal(t, "cursor42", result.NextCursor) -} +func TestList(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + richNode := `{ + "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 := `{"data":{"repository":{"hasDiscussionsEnabled":false,"discussions":{"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""},"nodes":[]}}}}` -func TestList_filters(t *testing.T) { tests := []struct { - name string - filters ListFilters + name string + filters ListFilters + after string + limit int + responses []string + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { - name: "open state", - filters: ListFilters{State: ptr(FilterStateOpen)}, + name: "maps all fields", + limit: 10, + responses: []string{listResp(false, "", 1, richNode)}, + wantTotal: 1, + wantLen: 1, + wantDisc: &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: "discussions disabled", + limit: 10, + responses: []string{disabledResp}, + wantErr: "discussions disabled", }, { - name: "closed state", - filters: ListFilters{State: ptr(FilterStateClosed)}, + name: "limit zero", + limit: 0, + wantErr: "limit argument must be positive", }, { - name: "answered", - filters: ListFilters{Answered: ptr(true)}, + name: "invalid orderBy", + limit: 10, + filters: ListFilters{OrderBy: "invalid"}, + wantErr: "unknown order-by field", }, { - name: "unanswered", - filters: ListFilters{Answered: ptr(false)}, + name: "invalid direction", + limit: 10, + filters: ListFilters{Direction: "sideways"}, + wantErr: "unknown order direction", }, { - name: "category ID", - filters: ListFilters{CategoryID: "CAT123"}, + name: "invalid state", + limit: 10, + filters: ListFilters{State: new("merged")}, + wantErr: "unknown state filter", }, { - name: "order by created asc", - filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + name: "with after cursor", + limit: 10, + after: "someCursor", + responses: []string{emptyResp}, }, { - name: "order by updated desc", - filters: ListFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + name: "open state filter", + limit: 10, + filters: ListFilters{State: new(FilterStateOpen)}, + responses: []string{emptyResp}, + }, + { + name: "closed state filter", + limit: 10, + filters: ListFilters{State: new(FilterStateClosed)}, + responses: []string{emptyResp}, + }, + { + name: "answered filter", + limit: 10, + filters: ListFilters{Answered: new(true)}, + responses: []string{emptyResp}, + }, + { + name: "unanswered filter", + limit: 10, + filters: ListFilters{Answered: new(false)}, + responses: []string{emptyResp}, + }, + { + name: "category ID filter", + limit: 10, + filters: ListFilters{CategoryID: "CAT123"}, + responses: []string{emptyResp}, + }, + { + name: "order by created asc", + limit: 10, + filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + responses: []string{emptyResp}, + }, + { + name: "order by updated desc", + limit: 10, + filters: ListFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + responses: []string{emptyResp}, + }, + { + // When the page has more items than requested, NextCursor is set. + name: "pagination sets next cursor", + limit: 1, + responses: []string{ + 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, + responses: []string{ + listResp(true, "cursor1", 2, minimalNode("D1", "First")), + listResp(false, "", 2, minimalNode("D2", "Second")), + }, + wantLen: 2, + wantTotal: 2, + wantTitles: []string{"First", "Second"}, }, } @@ -255,18 +233,33 @@ func TestList_filters(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - reg.Register( - httpmock.GraphQL(`query DiscussionList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": true, - "discussions": {"totalCount": 0, "pageInfo": {"hasNextPage": false, "endCursor": ""}, "nodes": []} - }}}`), - ) + for _, resp := range tt.responses { + reg.Register(httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(resp)) + } c := newTestDiscussionClient(reg) - result, err := c.List(ghrepo.New("OWNER", "REPO"), tt.filters, "", 10) + 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) - assert.NotNil(t, result) + 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.wantDisc != nil { + require.NotEmpty(t, result.Discussions) + assert.Equal(t, *tt.wantDisc, result.Discussions[0]) + } }) } } @@ -275,121 +268,173 @@ func TestList_filters(t *testing.T) { // Search // --------------------------------------------------------------------------- -func TestSearch_success(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - reg.Register( - httpmock.GraphQL(`query DiscussionListSearch\b`), - httpmock.StringResponse(`{"data":{"search":{ - "discussionCount": 1, - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "D1", "number": 1, "title": "Searched discussion", "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 - }] - }}}`), - ) - - c := newTestDiscussionClient(reg) - result, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 10) - require.NoError(t, err) - require.NotNil(t, result) - assert.Equal(t, 1, result.TotalCount) - assert.Len(t, result.Discussions, 1) - assert.Equal(t, "Searched discussion", result.Discussions[0].Title) -} - -func TestSearch_limitZero(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 0) - require.Error(t, err) - assert.Contains(t, err.Error(), "limit argument must be positive") -} - -func TestSearch_invalidOrderBy(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) +func TestSearch(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + richNode := `{ + "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, "") - c := newTestDiscussionClient(reg) - _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{OrderBy: "bogus"}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown order-by field") -} - -func TestSearch_invalidDirection(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{Direction: "sideways"}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown order direction") -} - -func TestSearch_invalidState(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - c := newTestDiscussionClient(reg) - _, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{State: ptr("merged")}, "", 10) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown state filter") -} - -func TestSearch_filters(t *testing.T) { tests := []struct { - name string - filters SearchFilters + name string + filters SearchFilters + after string + limit int + responses []string + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { - name: "open state", - filters: SearchFilters{State: ptr(FilterStateOpen)}, + name: "maps all fields", + limit: 10, + responses: []string{searchResp(false, "", 1, richNode)}, + wantTotal: 1, + wantLen: 1, + wantDisc: &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", + responses: []string{emptyResp}, + }, + { + name: "open state filter", + limit: 10, + filters: SearchFilters{State: new(FilterStateOpen)}, + responses: []string{emptyResp}, + }, + { + name: "closed state filter", + limit: 10, + filters: SearchFilters{State: new(FilterStateClosed)}, + responses: []string{emptyResp}, }, { - name: "closed state", - filters: SearchFilters{State: ptr(FilterStateClosed)}, + name: "answered filter", + limit: 10, + filters: SearchFilters{Answered: new(true)}, + responses: []string{emptyResp}, }, { - name: "answered", - filters: SearchFilters{Answered: ptr(true)}, + name: "unanswered filter", + limit: 10, + filters: SearchFilters{Answered: new(false)}, + responses: []string{emptyResp}, }, { - name: "unanswered", - filters: SearchFilters{Answered: ptr(false)}, + name: "author filter", + limit: 10, + filters: SearchFilters{Author: "alice"}, + responses: []string{emptyResp}, }, { - name: "author", - filters: SearchFilters{Author: "alice"}, + name: "labels filter", + limit: 10, + filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, + responses: []string{emptyResp}, }, { - name: "labels", - filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, + name: "category filter", + limit: 10, + filters: SearchFilters{Category: "Q&A"}, + responses: []string{emptyResp}, }, { - name: "category", - filters: SearchFilters{Category: "Q&A"}, + name: "keywords filter", + limit: 10, + filters: SearchFilters{Keywords: "some keyword"}, + responses: []string{emptyResp}, }, { - name: "keywords", - filters: SearchFilters{Keywords: "some keyword"}, + name: "order by created asc", + limit: 10, + filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + responses: []string{emptyResp}, }, { - name: "order by created asc", - filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, + name: "order by updated desc", + limit: 10, + filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + responses: []string{emptyResp}, }, { - name: "order by updated desc", - filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, + // When the page has more items than requested, NextCursor is set. + name: "pagination sets next cursor", + limit: 1, + responses: []string{ + 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, + responses: []string{ + searchResp(true, "searchCursor1", 2, minimalNode("D1", "First")), + searchResp(false, "", 2, minimalNode("D2", "Second")), + }, + wantLen: 2, + wantTotal: 2, + wantTitles: []string{"First", "Second"}, }, } @@ -398,107 +443,95 @@ func TestSearch_filters(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - reg.Register( - httpmock.GraphQL(`query DiscussionListSearch\b`), - httpmock.StringResponse(`{"data":{"search":{ - "discussionCount": 0, - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [] - }}}`), - ) + for _, resp := range tt.responses { + reg.Register(httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(resp)) + } c := newTestDiscussionClient(reg) - result, err := c.Search(ghrepo.New("OWNER", "REPO"), tt.filters, "", 10) + 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) - assert.NotNil(t, result) - }) - } -} + 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) -func TestSearch_pagination(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - makeNode := func(id, title string) string { - return `{ - "id": "` + id + `", "number": 1, "title": "` + title + `", "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 - }` - } + for i, title := range tt.wantTitles { + assert.Equal(t, title, result.Discussions[i].Title) + } - reg.Register( - httpmock.GraphQL(`query DiscussionListSearch\b`), - httpmock.StringResponse(`{"data":{"search":{ - "discussionCount": 2, - "pageInfo": {"hasNextPage": true, "endCursor": "searchCursor1"}, - "nodes": [`+makeNode("D1", "First")+`] - }}}`), - ) - reg.Register( - httpmock.GraphQL(`query DiscussionListSearch\b`), - httpmock.StringResponse(`{"data":{"search":{ - "discussionCount": 2, - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [`+makeNode("D2", "Second")+`] - }}}`), - ) - - c := newTestDiscussionClient(reg) - result, err := c.Search(ghrepo.New("OWNER", "REPO"), SearchFilters{}, "", 2) - require.NoError(t, err) - assert.Len(t, result.Discussions, 2) - assert.Equal(t, "First", result.Discussions[0].Title) - assert.Equal(t, "Second", result.Discussions[1].Title) + if tt.wantDisc != nil { + require.NotEmpty(t, result.Discussions) + assert.Equal(t, *tt.wantDisc, result.Discussions[0]) + } + }) + } } // --------------------------------------------------------------------------- // ListCategories // --------------------------------------------------------------------------- -func TestListCategories_success(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - 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} - ]} - }}}`), - ) - - c := newTestDiscussionClient(reg) - categories, err := c.ListCategories(ghrepo.New("OWNER", "REPO")) - require.NoError(t, err) - require.Len(t, categories, 2) - assert.Equal(t, "General", categories[0].Name) - assert.Equal(t, "Q&A", categories[1].Name) - assert.True(t, categories[1].IsAnswerable) -} +func TestListCategories(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + response string + wantErr string + wantCats []DiscussionCategory + }{ + { + name: "maps all fields", + response: `{"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", + response: `{"data":{"repository":{ + "hasDiscussionsEnabled":false, + "discussionCategories":{"nodes":[]} + }}}`, + wantErr: "discussions disabled", + }, + } -func TestListCategories_discussionsDisabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - - reg.Register( - httpmock.GraphQL(`query DiscussionCategoryList\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasDiscussionsEnabled": false, - "discussionCategories": {"nodes": []} - }}}`), - ) - - c := newTestDiscussionClient(reg) - _, err := c.ListCategories(ghrepo.New("OWNER", "REPO")) - require.Error(t, err) - assert.Contains(t, err.Error(), "discussions disabled") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register(httpmock.GraphQL(`query DiscussionCategoryList\b`), httpmock.StringResponse(tt.response)) + + 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]) + } + }) + } } From 5db230b3171ede930d3033688a929f4778f4de37 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 22 Apr 2026 16:36:07 -0500 Subject: [PATCH 25/81] test(discussion): assert GQL variables, add Bot actor and limit>100 cases - Replace false-positive filter tests with GraphQLQuery responders that assert on actual GQL variables (first, after, states, answered, orderBy, categoryId in List; query string qualifiers in Search) - Add Bot actor test case (Bot.ID maps to DiscussionActor.ID, Name is empty) - Add limit>100 test cases for both List and Search to verify the per-iteration first variable is set correctly (100 on page 1, remainder on page 2) - Fix limit>100 bug in client_impl.go: move variables["first"] assignment inside the loop so each iteration caps at min(remaining, 100) - Remove intStr helper; use strconv.Itoa directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 2 + pkg/cmd/discussion/client/client_impl_test.go | 295 +++++++++++++++--- 2 files changed, 246 insertions(+), 51 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index ed44e49bec2..c14d22a7744 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -214,6 +214,7 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte remaining := limit for { + variables["first"] = githubv4.Int(min(remaining, 100)) if err := c.gql.Query(repo.RepoHost(), "DiscussionList", &query, variables); err != nil { return nil, err } @@ -336,6 +337,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, remaining := limit for { + variables["first"] = githubv4.Int(min(remaining, 100)) if err := c.gql.Query(repo.RepoHost(), "DiscussionListSearch", &query, variables); err != nil { return nil, err } diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index cb4a89f62ff..1599a2b5be3 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2,6 +2,8 @@ package client import ( "net/http" + "strconv" + "strings" "testing" "time" @@ -22,6 +24,15 @@ func minimalNode(id, title string) string { return `{"id":"` + id + `","number":1,"title":"` + title + `","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}` } +// 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("D"+strconv.Itoa(i+1), "Discussion "+strconv.Itoa(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 { hasNextStr := "false" @@ -29,7 +40,7 @@ func listResp(hasNext bool, cursor string, total int, nodes string) string { hasNextStr = "true" } return `{"data":{"repository":{"hasDiscussionsEnabled":true,"discussions":{"totalCount":` + - intStr(total) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}}` + strconv.Itoa(total) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}}` } // searchResp builds a mock search JSON response. @@ -39,30 +50,7 @@ func searchResp(hasNext bool, cursor string, count int, nodes string) string { hasNextStr = "true" } return `{"data":{"search":{"discussionCount":` + - intStr(count) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}` -} - -// intStr converts an int to its decimal string representation without importing strconv. -func intStr(n int) string { - if n == 0 { - return "0" - } - neg := n < 0 - if neg { - n = -n - } - buf := [20]byte{} - pos := len(buf) - for n > 0 { - pos-- - buf[pos] = byte('0' + n%10) - n /= 10 - } - if neg { - pos-- - buf[pos] = '-' - } - return string(buf[pos:]) + strconv.Itoa(count) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}` } // --------------------------------------------------------------------------- @@ -90,17 +78,18 @@ func TestList(t *testing.T) { disabledResp := `{"data":{"repository":{"hasDiscussionsEnabled":false,"discussions":{"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""},"nodes":[]}}}}` tests := []struct { - name string - filters ListFilters - after string - limit int - responses []string - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + name string + filters ListFilters + after string + limit int + responses []string + checkVarsFns []func(*testing.T, map[string]interface{}) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { name: "maps all fields", @@ -160,48 +149,143 @@ func TestList(t *testing.T) { limit: 10, after: "someCursor", responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, "someCursor", vars["after"]) + }, + }, }, { name: "open state filter", limit: 10, filters: ListFilters{State: new(FilterStateOpen)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, []interface{}{"OPEN"}, vars["states"]) + }, + }, }, { name: "closed state filter", limit: 10, filters: ListFilters{State: new(FilterStateClosed)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, []interface{}{"CLOSED"}, vars["states"]) + }, + }, }, { name: "answered filter", limit: 10, filters: ListFilters{Answered: new(true)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, true, vars["answered"]) + }, + }, }, { name: "unanswered filter", limit: 10, filters: ListFilters{Answered: new(false)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, false, vars["answered"]) + }, + }, }, { name: "category ID filter", limit: 10, filters: ListFilters{CategoryID: "CAT123"}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, "CAT123", vars["categoryId"]) + }, + }, }, { name: "order by created asc", limit: 10, filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + 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}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + 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, + responses: []string{listResp(false, "", 1, `{"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, + wantDisc: &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, + responses: []string{ + listResp(true, "pg2cursor", 101, minimalNodes(100)), + listResp(false, "", 101, minimalNode("D101", "Discussion 101")), + }, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, float64(100), vars["first"]) + }, + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, float64(1), vars["first"]) + }, + }, + wantLen: 101, + wantTotal: 101, }, { // When the page has more items than requested, NextCursor is set. @@ -233,8 +317,17 @@ func TestList(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - for _, resp := range tt.responses { - reg.Register(httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(resp)) + for i, resp := range tt.responses { + var responder httpmock.Responder + if i < len(tt.checkVarsFns) && tt.checkVarsFns[i] != nil { + fn := tt.checkVarsFns[i] + responder = httpmock.GraphQLQuery(resp, func(_ string, vars map[string]interface{}) { + fn(t, vars) + }) + } else { + responder = httpmock.StringResponse(resp) + } + reg.Register(httpmock.GraphQL(`query DiscussionList\b`), responder) } c := newTestDiscussionClient(reg) @@ -288,17 +381,18 @@ func TestSearch(t *testing.T) { emptyResp := searchResp(false, "", 0, "") tests := []struct { - name string - filters SearchFilters - after string - limit int - responses []string - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + name string + filters SearchFilters + after string + limit int + responses []string + checkVarsFns []func(*testing.T, map[string]interface{}) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { name: "maps all fields", @@ -352,66 +446,156 @@ func TestSearch(t *testing.T) { limit: 10, after: "someCursor", responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, "someCursor", vars["after"]) + }, + }, }, { name: "open state filter", limit: 10, filters: SearchFilters{State: new(FilterStateOpen)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "is:open") + }, + }, }, { name: "closed state filter", limit: 10, filters: SearchFilters{State: new(FilterStateClosed)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "is:closed") + }, + }, }, { name: "answered filter", limit: 10, filters: SearchFilters{Answered: new(true)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "is:answered") + }, + }, }, { name: "unanswered filter", limit: 10, filters: SearchFilters{Answered: new(false)}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "is:unanswered") + }, + }, }, { name: "author filter", limit: 10, filters: SearchFilters{Author: "alice"}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), `author:"alice"`) + }, + }, }, { name: "labels filter", limit: 10, filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + 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"}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), `category:"Q&A"`) + }, + }, }, { name: "keywords filter", limit: 10, filters: SearchFilters{Keywords: "some keyword"}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "some keyword") + }, + }, }, { name: "order by created asc", limit: 10, filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Contains(t, vars["query"].(string), "sort:created-asc") + }, + }, }, { name: "order by updated desc", limit: 10, filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, responses: []string{emptyResp}, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + 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, + responses: []string{ + searchResp(true, "pg2cursor", 101, minimalNodes(100)), + searchResp(false, "", 101, minimalNode("D101", "Discussion 101")), + }, + checkVarsFns: []func(*testing.T, map[string]interface{}){ + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, float64(100), vars["first"]) + }, + func(t *testing.T, vars map[string]interface{}) { + t.Helper() + assert.Equal(t, float64(1), vars["first"]) + }, + }, + wantLen: 101, + wantTotal: 101, }, { // When the page has more items than requested, NextCursor is set. @@ -443,8 +627,17 @@ func TestSearch(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - for _, resp := range tt.responses { - reg.Register(httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(resp)) + for i, resp := range tt.responses { + var responder httpmock.Responder + if i < len(tt.checkVarsFns) && tt.checkVarsFns[i] != nil { + fn := tt.checkVarsFns[i] + responder = httpmock.GraphQLQuery(resp, func(_ string, vars map[string]interface{}) { + fn(t, vars) + }) + } else { + responder = httpmock.StringResponse(resp) + } + reg.Register(httpmock.GraphQL(`query DiscussionListSearch\b`), responder) } c := newTestDiscussionClient(reg) From 45de7db4d5b607fbe25a5a88f9bd0e62d8875abe Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 22 Apr 2026 16:49:22 -0500 Subject: [PATCH 26/81] style: run gofmt on client_impl_test.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 122 +++++++++--------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 1599a2b5be3..f0672323582 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -78,18 +78,18 @@ func TestList(t *testing.T) { disabledResp := `{"data":{"repository":{"hasDiscussionsEnabled":false,"discussions":{"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""},"nodes":[]}}}}` tests := []struct { - name string - filters ListFilters - after string - limit int - responses []string - checkVarsFns []func(*testing.T, map[string]interface{}) - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + name string + filters ListFilters + after string + limit int + responses []string + checkVarsFns []func(*testing.T, map[string]interface{}) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { name: "maps all fields", @@ -99,20 +99,20 @@ func TestList(t *testing.T) { wantLen: 1, wantDisc: &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, + 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, + 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, }, }, { @@ -248,19 +248,19 @@ func TestList(t *testing.T) { }, { // Bot actors have no name; ID comes from the Bot.ID field. - name: "bot actor", - limit: 10, + name: "bot actor", + limit: 10, responses: []string{listResp(false, "", 1, `{"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, wantDisc: &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{}, + 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), }, @@ -381,18 +381,18 @@ func TestSearch(t *testing.T) { emptyResp := searchResp(false, "", 0, "") tests := []struct { - name string - filters SearchFilters - after string - limit int - responses []string - checkVarsFns []func(*testing.T, map[string]interface{}) - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + name string + filters SearchFilters + after string + limit int + responses []string + checkVarsFns []func(*testing.T, map[string]interface{}) + wantErr string + wantTotal int + wantLen int + wantCursor string + wantTitles []string + wantDisc *Discussion }{ { name: "maps all fields", @@ -402,20 +402,20 @@ func TestSearch(t *testing.T) { wantLen: 1, wantDisc: &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, + 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, + 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, }, }, { @@ -675,10 +675,10 @@ func TestListCategories(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { - name string - response string - wantErr string - wantCats []DiscussionCategory + name string + response string + wantErr string + wantCats []DiscussionCategory }{ { name: "maps all fields", From 81117364ba32b100758a345935d4d50e17a241f5 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 14:02:43 +0100 Subject: [PATCH 27/81] refactor(discussion/client): convert tests to httpStubs pattern Replace responses/checkVarsFns with httpStubs func per test case in TestList, TestSearch, and TestListCategories, matching the pattern used in agent-task/capi tests. Expand inline JSON to heredoc, remove flower box comments, and add "empty list" and "exact fit" test cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 1024 +++++++++++------ 1 file changed, 651 insertions(+), 373 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index f0672323582..0a63927989b 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1,12 +1,13 @@ package client import ( + "fmt" "net/http" - "strconv" "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" @@ -21,36 +22,87 @@ func newTestDiscussionClient(reg *httpmock.Registry) DiscussionClient { // minimalNode returns a minimal JSON discussion node with the given id and title. func minimalNode(id, title string) string { - return `{"id":"` + id + `","number":1,"title":"` + title + `","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}` + 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("D"+strconv.Itoa(i+1), "Discussion "+strconv.Itoa(i+1)) + 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 { - hasNextStr := "false" - if hasNext { - hasNextStr = "true" - } - return `{"data":{"repository":{"hasDiscussionsEnabled":true,"discussions":{"totalCount":` + - strconv.Itoa(total) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}}` + 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 { - hasNextStr := "false" - if hasNext { - hasNextStr = "true" - } - return `{"data":{"search":{"discussionCount":` + - strconv.Itoa(count) + `,"pageInfo":{"hasNextPage":` + hasNextStr + `,"endCursor":"` + cursor + `"},"nodes":[` + nodes + `]}}}` + return heredoc.Docf(` + { + "data": { + "search": { + "discussionCount": %d, + "pageInfo": { + "hasNextPage": %t, + "endCursor": %q + }, + "nodes": [%s] + } + } + } + `, count, hasNext, cursor, nodes) } // --------------------------------------------------------------------------- @@ -60,66 +112,153 @@ func searchResp(hasNext bool, cursor string, count int, nodes string) string { func TestList(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") - richNode := `{ - "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 - }` + 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 := `{"data":{"repository":{"hasDiscussionsEnabled":false,"discussions":{"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""},"nodes":[]}}}}` + disabledResp := heredoc.Doc(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": false, + "discussions": { + "totalCount": 0, + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + }, + "nodes": [] + } + } + } + } + `) tests := []struct { - name string - filters ListFilters - after string - limit int - responses []string - checkVarsFns []func(*testing.T, map[string]interface{}) - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + 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, - responses: []string{listResp(false, "", 1, richNode)}, + 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, - wantDisc: &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"}}, + 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, + 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, - responses: []string{disabledResp}, - wantErr: "discussions disabled", + 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", @@ -145,115 +284,161 @@ func TestList(t *testing.T) { wantErr: "unknown state filter", }, { - name: "with after cursor", - limit: 10, - after: "someCursor", - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, "someCursor", vars["after"]) - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, []interface{}{"OPEN"}, vars["states"]) - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, []interface{}{"CLOSED"}, 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, true, vars["answered"]) - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, false, 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"}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, "CAT123", vars["categoryId"]) - }, + 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}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - 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 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}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - 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"]) - }, + 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, - responses: []string{listResp(false, "", 1, `{"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}`)}, + 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, - wantDisc: &Discussion{ + wantSingleDisc: &Discussion{ ID: "D_bot", Number: 1, Title: "Bot post", @@ -270,19 +455,19 @@ func TestList(t *testing.T) { // requests the remainder, exercising the per-iteration first variable. name: "limit greater than 100", limit: 101, - responses: []string{ - listResp(true, "pg2cursor", 101, minimalNodes(100)), - listResp(false, "", 101, minimalNode("D101", "Discussion 101")), - }, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, float64(100), vars["first"]) - }, - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, float64(1), vars["first"]) - }, + 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, @@ -291,8 +476,11 @@ func TestList(t *testing.T) { // When the page has more items than requested, NextCursor is set. name: "pagination sets next cursor", limit: 1, - responses: []string{ - listResp(true, "cursor42", 5, minimalNode("D1", "Discussion 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, @@ -302,14 +490,33 @@ func TestList(t *testing.T) { // Two pages are fetched when limit exceeds the first page's results. name: "pagination fetches multiple pages", limit: 2, - responses: []string{ - listResp(true, "cursor1", 2, minimalNode("D1", "First")), - listResp(false, "", 2, minimalNode("D2", "Second")), + 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 { @@ -317,17 +524,8 @@ func TestList(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - for i, resp := range tt.responses { - var responder httpmock.Responder - if i < len(tt.checkVarsFns) && tt.checkVarsFns[i] != nil { - fn := tt.checkVarsFns[i] - responder = httpmock.GraphQLQuery(resp, func(_ string, vars map[string]interface{}) { - fn(t, vars) - }) - } else { - responder = httpmock.StringResponse(resp) - } - reg.Register(httpmock.GraphQL(`query DiscussionList\b`), responder) + if tt.httpStubs != nil { + tt.httpStubs(t, reg) } c := newTestDiscussionClient(reg) @@ -349,73 +547,121 @@ func TestList(t *testing.T) { assert.Equal(t, title, result.Discussions[i].Title) } - if tt.wantDisc != nil { + if tt.wantSingleDisc != nil { require.NotEmpty(t, result.Discussions) - assert.Equal(t, *tt.wantDisc, result.Discussions[0]) + assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) } }) } } -// --------------------------------------------------------------------------- -// Search -// --------------------------------------------------------------------------- - func TestSearch(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") - richNode := `{ - "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 - }` + 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 - responses []string - checkVarsFns []func(*testing.T, map[string]interface{}) - wantErr string - wantTotal int - wantLen int - wantCursor string - wantTitles []string - wantDisc *Discussion + 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, - responses: []string{searchResp(false, "", 1, richNode)}, + 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, - wantDisc: &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"}}, + 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, + 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, }, }, { @@ -442,137 +688,148 @@ func TestSearch(t *testing.T) { wantErr: "unknown state filter", }, { - name: "with after cursor", - limit: 10, - after: "someCursor", - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, "someCursor", vars["after"]) - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "is:open") - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "is:closed") - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "is:answered") - }, + 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)}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "is:unanswered") - }, + 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"}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), `author:"alice"`) - }, + 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"}}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - q := vars["query"].(string) - assert.Contains(t, q, `label:"bug"`) - assert.Contains(t, q, `label:"enhancement"`) - }, + 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"}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), `category:"Q&A"`) - }, + 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"}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "some keyword") - }, + 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}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "sort:created-asc") - }, + 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}, - responses: []string{emptyResp}, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Contains(t, vars["query"].(string), "sort:updated-desc") - }, + 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") + }), + ) }, }, { @@ -580,19 +837,19 @@ func TestSearch(t *testing.T) { // requests the remainder, exercising the per-iteration first variable. name: "limit greater than 100", limit: 101, - responses: []string{ - searchResp(true, "pg2cursor", 101, minimalNodes(100)), - searchResp(false, "", 101, minimalNode("D101", "Discussion 101")), - }, - checkVarsFns: []func(*testing.T, map[string]interface{}){ - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, float64(100), vars["first"]) - }, - func(t *testing.T, vars map[string]interface{}) { - t.Helper() - assert.Equal(t, float64(1), vars["first"]) - }, + 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, @@ -601,8 +858,11 @@ func TestSearch(t *testing.T) { // When the page has more items than requested, NextCursor is set. name: "pagination sets next cursor", limit: 1, - responses: []string{ - searchResp(true, "searchCursor42", 5, minimalNode("D1", "Discussion 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, @@ -612,14 +872,33 @@ func TestSearch(t *testing.T) { // Two pages are fetched when limit exceeds the first page's results. name: "pagination fetches multiple pages", limit: 2, - responses: []string{ - searchResp(true, "searchCursor1", 2, minimalNode("D1", "First")), - searchResp(false, "", 2, minimalNode("D2", "Second")), + 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 { @@ -627,17 +906,8 @@ func TestSearch(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - for i, resp := range tt.responses { - var responder httpmock.Responder - if i < len(tt.checkVarsFns) && tt.checkVarsFns[i] != nil { - fn := tt.checkVarsFns[i] - responder = httpmock.GraphQLQuery(resp, func(_ string, vars map[string]interface{}) { - fn(t, vars) - }) - } else { - responder = httpmock.StringResponse(resp) - } - reg.Register(httpmock.GraphQL(`query DiscussionListSearch\b`), responder) + if tt.httpStubs != nil { + tt.httpStubs(t, reg) } c := newTestDiscussionClient(reg) @@ -659,36 +929,37 @@ func TestSearch(t *testing.T) { assert.Equal(t, title, result.Discussions[i].Title) } - if tt.wantDisc != nil { + if tt.wantSingleDisc != nil { require.NotEmpty(t, result.Discussions) - assert.Equal(t, *tt.wantDisc, result.Discussions[0]) + assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) } }) } } -// --------------------------------------------------------------------------- -// ListCategories -// --------------------------------------------------------------------------- - func TestListCategories(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { - name string - response string - wantErr string - wantCats []DiscussionCategory + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantCats []DiscussionCategory }{ { name: "maps all fields", - response: `{"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} - ]} - }}}`, + 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}, @@ -696,10 +967,15 @@ func TestListCategories(t *testing.T) { }, { name: "discussions disabled", - response: `{"data":{"repository":{ - "hasDiscussionsEnabled":false, - "discussionCategories":{"nodes":[]} - }}}`, + 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", }, } @@ -709,7 +985,9 @@ func TestListCategories(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - reg.Register(httpmock.GraphQL(`query DiscussionCategoryList\b`), httpmock.StringResponse(tt.response)) + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } c := newTestDiscussionClient(reg) categories, err := c.ListCategories(repo) From 2b794ed99213b7b11ca8bdccb22b604ab19dba3d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 14:21:24 +0100 Subject: [PATCH 28/81] refactor(discussion/client): remove redundant "first" variable init The fetch loop already assigns "first" on each iteration, so the initial assignment in the variables map is dead code. Remove it from both List and Search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index c14d22a7744..d3f8e817bcc 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -11,6 +11,9 @@ import ( "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 } @@ -171,15 +174,9 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte } } - perPage := limit - if perPage > 100 { - perPage = 100 - } - variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), - "first": githubv4.Int(perPage), "after": (*githubv4.String)(nil), "orderBy": githubv4.DiscussionOrder{Field: orderField, Direction: orderDir}, "categoryId": (*githubv4.ID)(nil), @@ -214,7 +211,7 @@ func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, afte remaining := limit for { - variables["first"] = githubv4.Int(min(remaining, 100)) + variables["first"] = githubv4.Int(min(remaining, maxPageSize)) if err := c.gql.Query(repo.RepoHost(), "DiscussionList", &query, variables); err != nil { return nil, err } @@ -319,14 +316,8 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, searchQuery += " " + filters.Keywords } - perPage := limit - if perPage > 100 { - perPage = 100 - } - variables := map[string]interface{}{ "query": githubv4.String(searchQuery), - "first": githubv4.Int(perPage), "after": (*githubv4.String)(nil), } if after != "" { @@ -337,7 +328,7 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, remaining := limit for { - variables["first"] = githubv4.Int(min(remaining, 100)) + variables["first"] = githubv4.Int(min(remaining, maxPageSize)) if err := c.gql.Query(repo.RepoHost(), "DiscussionListSearch", &query, variables); err != nil { return nil, err } From d9e97518231adeb82b36e3188d51440519651160 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 3 Apr 2026 13:16:12 -0500 Subject: [PATCH 29/81] Add `discussion view` command Implement `gh discussion view` for viewing a single discussion with: - Number or URL argument via shared.ParseDiscussionArg - TTY output: title, metadata (state, category, author, age, comment count), labels, markdown-rendered body, reactions - Context-aware author attribution: "Asked by" for answerable categories (Q&A), "Started by" for others - Non-TTY output: key-value pairs matching `gh issue view` format - JSON output via Exporter (Discussion.ExportData) - --web flag to open in browser - Pager support for TTY output Also adds: - GetByNumber client method with not-found detection - shared.ParseDiscussionArg for number/URL/#number parsing - shared.ReactionGroupList for emoji reaction display Comment threading (--comments) is deferred to the next PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 49 +++- pkg/cmd/discussion/discussion.go | 5 + pkg/cmd/discussion/shared/display.go | 35 +++ pkg/cmd/discussion/shared/lookup.go | 40 +++ pkg/cmd/discussion/view/view.go | 232 +++++++++++++++ pkg/cmd/discussion/view/view_test.go | 353 +++++++++++++++++++++++ 6 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/discussion/shared/display.go create mode 100644 pkg/cmd/discussion/shared/lookup.go create mode 100644 pkg/cmd/discussion/view/view.go create mode 100644 pkg/cmd/discussion/view/view_test.go diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index d3f8e817bcc..ed5536fac32 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -351,8 +351,53 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, return &result, nil } -func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) { + query := fmt.Sprintf(`query DiscussionByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + hasDiscussionsEnabled + discussion(number: $number) { + %s + body + comments { totalCount } + } + } + }`, discussionFields) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + "number": number, + } + + type response struct { + Repository struct { + HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + Discussion *struct { + discussionNode + Body string `json:"body"` + Comments struct { + TotalCount int `json:"totalCount"` + } `json:"comments"` + } `json:"discussion"` + } `json:"repository"` + } + + var data response + err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data) + if err != nil { + return nil, err + } + if !data.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + if data.Repository.Discussion == nil { + return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName()) + } + + d := mapDiscussion(data.Repository.Discussion.discussionNode) + d.Body = data.Repository.Discussion.Body + d.Comments = DiscussionCommentList{TotalCount: data.Repository.Discussion.Comments.TotalCount} + return &d, nil } func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) { diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index a9bf9b98120..a547638953d 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -3,6 +3,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" 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" ) @@ -36,5 +37,9 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdList.NewCmdList(f, nil), ) + cmdutil.AddGroup(cmd, "Targeted commands", + cmdView.NewCmdView(f, nil), + ) + return cmd } diff --git a/pkg/cmd/discussion/shared/display.go b/pkg/cmd/discussion/shared/display.go new file mode 100644 index 00000000000..ea866fcb2a8 --- /dev/null +++ b/pkg/cmd/discussion/shared/display.go @@ -0,0 +1,35 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/pkg/cmd/discussion/client" +) + +var reactionEmoji = map[string]string{ + "THUMBS_UP": "\U0001f44d", + "THUMBS_DOWN": "\U0001f44e", + "LAUGH": "\U0001f604", + "HOORAY": "\U0001f389", + "CONFUSED": "\U0001f615", + "HEART": "\u2764\ufe0f", + "ROCKET": "\U0001f680", + "EYES": "\U0001f440", +} + +// ReactionGroupList formats reaction groups for display. +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, " • ") +} diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go new file mode 100644 index 00000000000..1196dd47729 --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup.go @@ -0,0 +1,40 @@ +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: %s", arg) + } + + m := discussionURLRE.FindStringSubmatch(u.Path) + if m == nil { + return 0, nil, fmt.Errorf("invalid discussion URL: %s", 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/view/view.go b/pkg/cmd/discussion/view/view.go new file mode 100644 index 00000000000..788b7d7665a --- /dev/null +++ b/pkg/cmd/discussion/view/view.go @@ -0,0 +1,232 @@ +package view + +import ( + "fmt" + "io" + "sort" + "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" +) + +// 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 + 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", + Long: heredoc.Docf(` + Display the title, body, and other information about a discussion. + + 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 + + # Open in browser + $ gh discussion view 123 --web + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, repo, err := shared.ParseDiscussionArg(args[0]) + if err != nil { + return 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") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields) + + return cmd +} + +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() + + 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) +} + +func printHumanView(opts *ViewOptions, d *client.Discussion) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + numberStr := fmt.Sprintf("#%d", d.Number) + if d.State == "OPEN" { + 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.State != "OPEN" { + 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 := shared.ReactionGroupList(d.ReactionGroups); reactions != "" { + fmt.Fprintln(out, reactions) + 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) error { + fmt.Fprintf(out, "title:\t%s\n", d.Title) + fmt.Fprintf(out, "state:\t%s\n", d.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) + 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) + return nil +} + +func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string { + if len(labels) == 0 { + return "" + } + + sort.SliceStable(labels, func(i, j int) bool { + return strings.ToLower(labels[i].Name) < strings.ToLower(labels[j].Name) + }) + + names := make([]string, len(labels)) + for i, l := range labels { + if cs == nil { + names[i] = l.Name + } else { + names[i] = cs.Label(l.Color, l.Name) + } + } + return strings.Join(names, ", ") +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go new file mode 100644 index 00000000000..9694fcaa80e --- /dev/null +++ b/pkg/cmd/discussion/view/view_test.go @@ -0,0 +1,353 @@ +package view + +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/cmd/discussion/shared" + "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 testDiscussion() *client.Discussion { + return &client.Discussion{ + ID: "D_123", + Number: 123, + Title: "How to authenticate with SSO?", + Body: "I need help with SSO authentication.", + URL: "https://github.com/OWNER/REPO/discussions/123", + State: "OPEN", + Author: client.DiscussionAuthor{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 TestNewCmdView(t *testing.T) { + tests := []struct { + name string + args []string + wantNum int + wantErr string + }{ + { + name: "number argument", + args: []string{"123"}, + wantNum: 123, + }, + { + name: "hash number argument", + args: []string{"#456"}, + wantNum: 456, + }, + { + name: "URL argument", + args: []string{"https://github.com/OWNER/REPO/discussions/789"}, + wantNum: 789, + }, + { + name: "invalid argument", + args: []string{"not-a-number"}, + wantErr: "invalid discussion argument", + }, + { + name: "no arguments", + args: []string{}, + wantErr: "accepts 1 arg(s), received 0", + }, + } + + 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 + }) + + cmd.SetArgs(tt.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantNum, gotOpts.DiscussionNumber) + }) + } +} + +func TestViewRun_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "How to authenticate with SSO?") + assert.Contains(t, out, "#123") + assert.Contains(t, out, "Q&A") + assert.Contains(t, out, "Asked by") + assert.Contains(t, out, "monalisa") + assert.Contains(t, out, "3 comments") + assert.Contains(t, out, "help-wanted") + assert.Contains(t, out, "View this discussion on GitHub") +} + +func TestViewRun_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "title:\tHow to authenticate with SSO?") + assert.Contains(t, out, "state:\tOPEN") + assert.Contains(t, out, "category:\tQ&A") + assert.Contains(t, out, "author:\tmonalisa") + assert.Contains(t, out, "labels:\thelp-wanted") + assert.Contains(t, out, "number:\t123") + assert.Contains(t, out, "--") + assert.Contains(t, out, "I need help with SSO authentication.") +} + +func TestViewRun_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(shared.DiscussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"title"`) + assert.Contains(t, out, `"number"`) + assert.Contains(t, out, "How to authenticate with SSO?") +} + +func TestViewRun_web(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + b := &browser.Stub{} + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Browser: b, + DiscussionNumber: 123, + WebMode: true, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + b.Verify(t, "https://github.com/OWNER/REPO/discussions/123") + assert.Contains(t, stderr.String(), "Opening") +} + +func TestViewRun_urlArg(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + d.URL = "https://github.com/OTHER/REPO/discussions/42" + d.Number = 42 + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OTHER", repo.RepoOwner()) + assert.Equal(t, "REPO", repo.RepoName()) + assert.Equal(t, 42, number) + return d, nil + }, + } + + f := &cmdutil.Factory{} + 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 + opts.Client = func() (client.DiscussionClient, error) { + return mock, nil + } + return viewRun(opts) + }) + + cmd.SetArgs([]string{"https://github.com/OTHER/REPO/discussions/42"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, 42, gotOpts.DiscussionNumber) + + out := stdout.String() + assert.Contains(t, out, "number:\t42") +} + +func TestViewRun_answerable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + d.Category.IsAnswerable = true + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Asked by") +} + +func TestViewRun_notAnswerable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + d.Category.Name = "General" + d.Category.IsAnswerable = false + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Started by") + assert.NotContains(t, out, "Asked by") +} From d6e63f63d3da6f96f65b2ed3887d54595f0d649c Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Thu, 16 Apr 2026 17:03:16 -0500 Subject: [PATCH 30/81] fix(discussion view): align with post-review list changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite GetByNumber to use strongly-typed GraphQL query instead of removed raw string interpolation (discussionFields/discussionNode) - Fix State string references to use Closed bool throughout view.go - Fix DiscussionAuthor → DiscussionActor type rename in tests - Add ReactionGroups field to Discussion domain type and ExportData - Add computed "state" field to ExportData for JSON output - Add shared.DiscussionFields for view command's --json flag - Regenerate client mock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 60 +++++++++++------------- pkg/cmd/discussion/client/types.go | 13 +++++ pkg/cmd/discussion/shared/client.go | 25 ++++++++++ pkg/cmd/discussion/view/view.go | 10 ++-- pkg/cmd/discussion/view/view_test.go | 4 +- 5 files changed, 75 insertions(+), 37 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index ed5536fac32..9cba1f8035a 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -352,51 +352,47 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, } func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) { - query := fmt.Sprintf(`query DiscussionByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - hasDiscussionsEnabled - discussion(number: $number) { - %s - body - comments { totalCount } - } - } - }`, discussionFields) - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "name": repo.RepoName(), - "number": number, - } - - type response struct { + var query struct { Repository struct { - HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + HasDiscussionsEnabled bool Discussion *struct { - discussionNode - Body string `json:"body"` + discussionListNode + Body string Comments struct { - TotalCount int `json:"totalCount"` - } `json:"comments"` - } `json:"discussion"` - } `json:"repository"` + 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), } - var data response - err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data) + err := c.gql.Query(repo.RepoHost(), "DiscussionByNumber", &query, variables) if err != nil { return nil, err } - if !data.Repository.HasDiscussionsEnabled { + if !query.Repository.HasDiscussionsEnabled { return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } - if data.Repository.Discussion == nil { + if query.Repository.Discussion == nil { return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName()) } - d := mapDiscussion(data.Repository.Discussion.discussionNode) - d.Body = data.Repository.Discussion.Body - d.Comments = DiscussionCommentList{TotalCount: data.Repository.Discussion.Comments.TotalCount} + d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) + d.Body = query.Repository.Discussion.Body + 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 } diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index f3c58456c4d..0fffa9a8dc7 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -19,6 +19,7 @@ type Discussion struct { AnswerChosenAt time.Time AnswerChosenBy *DiscussionActor Comments DiscussionCommentList + ReactionGroups []ReactionGroup CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time @@ -44,6 +45,12 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { 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": @@ -79,6 +86,12 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { "totalCount": d.Comments.TotalCount, "nodes": comments, } + 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": diff --git a/pkg/cmd/discussion/shared/client.go b/pkg/cmd/discussion/shared/client.go index d0f34e04b78..96c3345e06c 100644 --- a/pkg/cmd/discussion/shared/client.go +++ b/pkg/cmd/discussion/shared/client.go @@ -7,6 +7,31 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" ) +// DiscussionFields lists all field names available for --json output +// on discussion commands that return a full discussion (e.g. view). +var DiscussionFields = []string{ + "id", + "number", + "title", + "body", + "url", + "closed", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "comments", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} + // 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. diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 788b7d7665a..c7942d432dc 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -140,7 +140,7 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { cs := opts.IO.ColorScheme() numberStr := fmt.Sprintf("#%d", d.Number) - if d.State == "OPEN" { + if !d.Closed { numberStr = cs.Green(numberStr) } else { numberStr = cs.Muted(numberStr) @@ -149,7 +149,7 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { state := "Open" stateColor := cs.Green - if d.State != "OPEN" { + if d.Closed { state = "Closed" stateColor = cs.Muted } @@ -199,7 +199,11 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { func printRawView(out io.Writer, d *client.Discussion) error { fmt.Fprintf(out, "title:\t%s\n", d.Title) - fmt.Fprintf(out, "state:\t%s\n", d.State) + 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)) diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 9694fcaa80e..1299f2f64f1 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -22,8 +22,8 @@ func testDiscussion() *client.Discussion { Title: "How to authenticate with SSO?", Body: "I need help with SSO authentication.", URL: "https://github.com/OWNER/REPO/discussions/123", - State: "OPEN", - Author: client.DiscussionAuthor{Login: "monalisa"}, + Closed: false, + Author: client.DiscussionActor{Login: "monalisa"}, Category: client.DiscussionCategory{ Name: "Q&A", Slug: "q-a", IsAnswerable: true, }, From ca84d4c6a3f80ef29fc253b914441b1315dd7000 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 17 Apr 2026 14:51:44 -0500 Subject: [PATCH 31/81] Add `discussion view --comments` with threaded display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the --comments flag for discussion view, showing threaded comments with replies. Features: - --comments flag fetches and displays discussion comments - --order flag (oldest/newest) controls comment ordering - Answer badge (✓ Answer) on marked answer comments - Threaded replies with indentation - Truncation messages when more replies exist than fetched - TTY: markdown-rendered comments with author/timestamp/reactions - Non-TTY: stable tab-delimited format for scripting - JSON: populated comment nodes via ExportData Implementation: - GetWithComments uses raw GraphQL to dynamically switch between first/last based on ordering. Fetches 30 comments with 4 replies each. Explicitly reverses for newest-first ordering. - --order without --comments returns a flag error - Reuses existing shared.ReactionGroupList for reaction display Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 247 ++++++++++++++++++++++- pkg/cmd/discussion/view/view.go | 117 ++++++++++- pkg/cmd/discussion/view/view_test.go | 208 +++++++++++++++++++ 3 files changed, 567 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 9cba1f8035a..e50e85caddf 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -396,8 +396,251 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc return &d, nil } -func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { + // Build the comments field with first/last based on order. + // "oldest" uses first (chronological), "newest" uses last (reverse chronological). + commentDirection := "first" + if order == "newest" { + commentDirection = "last" + } + + query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + hasDiscussionsEnabled + discussion(number: $number) { + id + number + title + body + url + closed + stateReason + author { login ... on User { id name } ... on Bot { id } } + category { id name slug emoji isAnswerable } + labels(first: 20) { nodes { id name color } } + isAnswered + answerChosenAt + answerChosenBy { login ... on User { id name } ... on Bot { id } } + reactionGroups { content users { totalCount } } + createdAt + updatedAt + closedAt + locked + comments(%s: %d) { + totalCount + nodes { + id + url + author { login ... on User { id name } ... on Bot { id } } + body + createdAt + isAnswer + upvoteCount + reactionGroups { content users { totalCount } } + replies(first: 4) { + totalCount + nodes { + id + url + author { login ... on User { id name } ... on Bot { id } } + body + createdAt + isAnswer + upvoteCount + reactionGroups { content users { totalCount } } + } + } + } + } + } + } + }`, commentDirection, commentLimit) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + "number": number, + } + + type actorJSON struct { + Login string `json:"login"` + ID string `json:"id"` + Name string `json:"name"` + } + + type reactionGroupJSON struct { + Content string `json:"content"` + Users struct { + TotalCount int `json:"totalCount"` + } `json:"users"` + } + + type commentJSON struct { + ID string `json:"id"` + URL string `json:"url"` + Author actorJSON `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + IsAnswer bool `json:"isAnswer"` + UpvoteCount int `json:"upvoteCount"` + ReactionGroups []reactionGroupJSON `json:"reactionGroups"` + Replies *struct { + TotalCount int `json:"totalCount"` + Nodes []commentJSON `json:"nodes"` + } `json:"replies"` + } + + type response struct { + Repository struct { + HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + Discussion *struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Closed bool `json:"closed"` + StateReason string `json:"stateReason"` + Author actorJSON `json:"author"` + Category struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Emoji string `json:"emoji"` + IsAnswerable bool `json:"isAnswerable"` + } `json:"category"` + Labels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"nodes"` + } `json:"labels"` + IsAnswered bool `json:"isAnswered"` + AnswerChosenAt time.Time `json:"answerChosenAt"` + AnswerChosenBy *actorJSON `json:"answerChosenBy"` + ReactionGroups []reactionGroupJSON `json:"reactionGroups"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ClosedAt time.Time `json:"closedAt"` + Locked bool `json:"locked"` + Comments struct { + TotalCount int `json:"totalCount"` + Nodes []commentJSON `json:"nodes"` + } `json:"comments"` + } `json:"discussion"` + } `json:"repository"` + } + + var data response + err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data) + if err != nil { + return nil, err + } + if !data.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + if data.Repository.Discussion == nil { + return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName()) + } + + src := data.Repository.Discussion + + mapActor := func(a actorJSON) DiscussionActor { + return DiscussionActor{ID: a.ID, Login: a.Login, Name: a.Name} + } + + mapReactions := func(groups []reactionGroupJSON) []ReactionGroup { + out := make([]ReactionGroup, len(groups)) + for i, rg := range groups { + out[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} + } + return out + } + + mapComment := func(c commentJSON) DiscussionComment { + dc := DiscussionComment{ + ID: c.ID, + URL: c.URL, + Author: mapActor(c.Author), + Body: c.Body, + CreatedAt: c.CreatedAt, + IsAnswer: c.IsAnswer, + UpvoteCount: c.UpvoteCount, + ReactionGroups: mapReactions(c.ReactionGroups), + } + if c.Replies != nil { + dc.TotalReplies = c.Replies.TotalCount + for _, r := range c.Replies.Nodes { + dc.Replies = append(dc.Replies, DiscussionComment{ + ID: r.ID, + URL: r.URL, + Author: mapActor(r.Author), + Body: r.Body, + CreatedAt: r.CreatedAt, + IsAnswer: r.IsAnswer, + UpvoteCount: r.UpvoteCount, + ReactionGroups: mapReactions(r.ReactionGroups), + }) + } + } + return dc + } + + d := Discussion{ + ID: src.ID, + Number: src.Number, + Title: src.Title, + Body: src.Body, + URL: src.URL, + Closed: src.Closed, + StateReason: src.StateReason, + Author: mapActor(src.Author), + Category: DiscussionCategory{ + ID: src.Category.ID, + Name: src.Category.Name, + Slug: src.Category.Slug, + Emoji: src.Category.Emoji, + IsAnswerable: src.Category.IsAnswerable, + }, + Answered: src.IsAnswered, + AnswerChosenAt: src.AnswerChosenAt, + ReactionGroups: mapReactions(src.ReactionGroups), + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + ClosedAt: src.ClosedAt, + Locked: src.Locked, + } + + if src.AnswerChosenBy != nil { + a := mapActor(*src.AnswerChosenBy) + d.AnswerChosenBy = &a + } + + d.Labels = make([]DiscussionLabel, len(src.Labels.Nodes)) + for i, l := range src.Labels.Nodes { + d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} + } + + comments := make([]DiscussionComment, len(src.Comments.Nodes)) + for i, c := range src.Comments.Nodes { + comments[i] = mapComment(c) + } + + // When using "last" (newest order), the API returns items in chronological + // order. Reverse them so the newest comment appears first. + if order == "newest" { + for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 { + comments[i], comments[j] = comments[j], comments[i] + } + } + + d.Comments = DiscussionCommentList{ + Comments: comments, + TotalCount: src.Comments.TotalCount, + } + + return &d, nil } func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index c7942d432dc..782311e59c9 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -28,6 +28,8 @@ type ViewOptions struct { DiscussionNumber int WebMode bool + Comments bool + Order string Exporter cmdutil.Exporter Now func() time.Time } @@ -46,6 +48,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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). + With %[1]s--web%[1]s flag, open the discussion in a web browser instead. `, "`"), Example: heredoc.Doc(` @@ -55,11 +60,21 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # 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 newest comments first + $ gh discussion view 123 --comments --order newest + # Open in browser $ gh discussion view 123 --web `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("order") && !opts.Comments { + return cmdutil.FlagErrorf("--order requires --comments") + } + number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { return err @@ -84,6 +99,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } 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") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "oldest", []string{"oldest", "newest"}, "Order of comments") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields) return cmd @@ -111,7 +128,12 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() - discussion, err := c.GetByNumber(repo, opts.DiscussionNumber) + var discussion *client.Discussion + if opts.Comments { + discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, 30, opts.Order) + } else { + discussion, err = c.GetByNumber(repo, opts.DiscussionNumber) + } opts.IO.StopProgressIndicator() @@ -132,7 +154,7 @@ func viewRun(opts *ViewOptions) error { return printHumanView(opts, discussion) } - return printRawView(opts.IO.Out, discussion) + return printRawView(opts.IO.Out, discussion, opts.Comments) } func printHumanView(opts *ViewOptions, d *client.Discussion) error { @@ -192,12 +214,29 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { 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 { + fmt.Fprintf(out, cs.Muted(" And %d more comments\n"), d.Comments.TotalCount-shown) + 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) error { +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 { @@ -212,9 +251,81 @@ func printRawView(out io.Writer, d *client.Discussion) error { 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 := shared.ReactionGroupList(c.ReactionGroups); reactions != "" { + fmt.Fprintf(out, "%s%s\n", indent, reactions) + } + + fmt.Fprintln(out) + + for _, reply := range c.Replies { + if err := printHumanComment(opts, out, reply, indent+" "); err != nil { + return err + } + } + + if shown := len(c.Replies); shown < c.TotalReplies { + fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d more replies", c.TotalReplies-shown))) + } + 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 { + printRawComment(out, reply, indent+" ") + } +} + func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string { if len(labels) == 0 { return "" diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 1299f2f64f1..6e90d131cbc 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -351,3 +351,211 @@ func TestViewRun_notAnswerable(t *testing.T) { assert.Contains(t, out, "Started by") assert.NotContains(t, out, "Asked by") } + +func testDiscussionWithComments() *client.Discussion { + d := testDiscussion() + 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.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), + }, + }, + TotalReplies: 5, + }, + { + 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 TestViewRun_comments_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + assert.Equal(t, 30, commentLimit) + assert.Equal(t, "oldest", order) + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Comments") + assert.Contains(t, out, "octocat") + assert.Contains(t, out, "✓ Answer") + assert.Contains(t, out, "This is a comment") + assert.Contains(t, out, "hubot") + assert.Contains(t, out, "Thanks!") + assert.Contains(t, out, "And 4 more replies") + assert.Contains(t, out, "monalisa") + assert.Contains(t, out, "Another comment") +} + +func TestViewRun_comments_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Order: "oldest", + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "comment:\toctocat\t") + assert.Contains(t, out, "answer") + assert.Contains(t, out, "This is a comment") + assert.Contains(t, out, "comment:\thubot\t") + assert.Contains(t, out, "comment:\tmonalisa\t") +} + +func TestViewRun_comments_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(shared.DiscussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Order: "oldest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"isAnswer":true`) + assert.Contains(t, out, `"octocat"`) +} + +func TestNewCmdView_orderWithoutComments(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{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--order", "newest"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--order requires --comments") +} + +func TestViewRun_noComments_usesGetByNumber(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + assert.Equal(t, 1, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) +} From dea54ab3ababe2d8b8712e2a72cc2079b9c114bf Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 20 Apr 2026 15:41:55 -0500 Subject: [PATCH 32/81] fix: gofmt alignment in GetWithComments response struct Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index e50e85caddf..7b4ad3ce9c6 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -494,14 +494,14 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co Repository struct { HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` Discussion *struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` - Closed bool `json:"closed"` - StateReason string `json:"stateReason"` - Author actorJSON `json:"author"` + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Closed bool `json:"closed"` + StateReason string `json:"stateReason"` + Author actorJSON `json:"author"` Category struct { ID string `json:"id"` Name string `json:"name"` From cd3b23bf36c008a49ebf8791d46ea5b2c89f4518 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 21:37:58 +0100 Subject: [PATCH 33/81] docs(discussion view): add preview remark Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 782311e59c9..7bfb379ca6c 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -44,7 +44,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "view { | }", - Short: "View a discussion", + Short: "View a discussion (preview)", Long: heredoc.Docf(` Display the title, body, and other information about a discussion. From 57b3e9091cd5d622ad86468b73e099204cba07f4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 21:53:10 +0100 Subject: [PATCH 34/81] fix(discussion/shared): anchor discussion url regexp Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/lookup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go index 1196dd47729..c1b78771a69 100644 --- a/pkg/cmd/discussion/shared/lookup.go +++ b/pkg/cmd/discussion/shared/lookup.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" ) -var discussionURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/discussions/(\d+)`) +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. From 7603a0c8ce0bb0dc588a3f93bc384b6442f550cf Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 21:53:56 +0100 Subject: [PATCH 35/81] fix(discussion/shared): quote arg in errors Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/lookup.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go index c1b78771a69..98fdacd13e8 100644 --- a/pkg/cmd/discussion/shared/lookup.go +++ b/pkg/cmd/discussion/shared/lookup.go @@ -26,12 +26,12 @@ func ParseDiscussionArg(arg string) (int, ghrepo.Interface, error) { u, err := url.Parse(arg) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return 0, nil, fmt.Errorf("invalid discussion argument: %s", arg) + return 0, nil, fmt.Errorf("invalid discussion argument: %q", arg) } m := discussionURLRE.FindStringSubmatch(u.Path) if m == nil { - return 0, nil, fmt.Errorf("invalid discussion URL: %s", arg) + return 0, nil, fmt.Errorf("invalid discussion URL: %q", arg) } num, _ := strconv.Atoi(m[3]) From ca8d878d80f44ec991968d91021f95c87bf1458b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 21:54:35 +0100 Subject: [PATCH 36/81] docs(discussion/shared): explain why we accept http scheme Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/lookup.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go index 98fdacd13e8..e754568f05d 100644 --- a/pkg/cmd/discussion/shared/lookup.go +++ b/pkg/cmd/discussion/shared/lookup.go @@ -29,6 +29,9 @@ func ParseDiscussionArg(arg string) (int, ghrepo.Interface, error) { 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) From f7a79683c0372b6eaff2973c9cc25d6521ab705d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 21:55:14 +0100 Subject: [PATCH 37/81] test(discussion/shared): add test for ParseDiscussionArg Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/shared/lookup_test.go | 119 +++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 pkg/cmd/discussion/shared/lookup_test.go 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) + } + }) + } +} From 6bd96abd6a76f2df6a300c094817f271e4f037f3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 22:01:51 +0100 Subject: [PATCH 38/81] fix(gh discussion view): wrap arg parse error as flag error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 7bfb379ca6c..8cb027b4411 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -77,7 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { - return err + return cmdutil.FlagErrorf("%s", err) } if repo != nil { From 75b71505c882e9707cf81eb618bad170aae42c82 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 22:19:59 +0100 Subject: [PATCH 39/81] refactor(discussion view): use slices package for label sorting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 8cb027b4411..ca94b238e0f 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -3,7 +3,7 @@ package view import ( "fmt" "io" - "sort" + "slices" "strings" "time" @@ -331,12 +331,13 @@ func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) strin return "" } - sort.SliceStable(labels, func(i, j int) bool { - return strings.ToLower(labels[i].Name) < strings.ToLower(labels[j].Name) + sortedLabels := slices.Clone(labels) + slices.SortStableFunc(sortedLabels, func(i, j client.DiscussionLabel) int { + return strings.Compare(i.Name, j.Name) }) - names := make([]string, len(labels)) - for i, l := range labels { + names := make([]string, len(sortedLabels)) + for i, l := range sortedLabels { if cs == nil { names[i] = l.Name } else { From d2e081bce13341b64c875510eb26393cc4a7d76f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 23:50:04 +0100 Subject: [PATCH 40/81] feat(discussion view): add cursor-based pagination to comments Add --limit and --after flags for paginating through discussion comments. Cursor output is shown in TTY (hint message), raw (next: field), and JSON. Change GetWithComments to accept 'after' cursor and 'newest' bool instead of order string. Implement forward/backward cursor-based pagination in GraphQL queries depending on comment order. Change Replies from []DiscussionComment to DiscussionCommentList with Direction field. Display direction-aware messages (newer/older) for both comments and replies. Move DiscussionFields and reactionGroupList from shared to view package. Delete shared/display.go. Add 7 new pagination tests and update existing test fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client.go | 2 +- pkg/cmd/discussion/client/client_impl.go | 81 ++++++-- pkg/cmd/discussion/client/client_mock.go | 26 ++- pkg/cmd/discussion/client/types.go | 32 ++- pkg/cmd/discussion/shared/client.go | 25 --- pkg/cmd/discussion/shared/display.go | 35 ---- pkg/cmd/discussion/view/view.go | 112 +++++++++- pkg/cmd/discussion/view/view_test.go | 251 +++++++++++++++++++++-- 8 files changed, 438 insertions(+), 126 deletions(-) delete mode 100644 pkg/cmd/discussion/shared/display.go diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index a794d13e13c..5c7f1bdc45e 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -12,7 +12,7 @@ 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, order string) (*Discussion, error) + GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 7b4ad3ce9c6..d83c9d35de0 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -3,6 +3,7 @@ package client import ( "fmt" "net/http" + "slices" "strings" "time" @@ -355,9 +356,8 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc var query struct { Repository struct { HasDiscussionsEnabled bool - Discussion *struct { + Discussion struct { discussionListNode - Body string Comments struct { TotalCount int } @@ -371,19 +371,15 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc "number": githubv4.Int(number), } - err := c.gql.Query(repo.RepoHost(), "DiscussionByNumber", &query, variables) + 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()) } - if query.Repository.Discussion == nil { - return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName()) - } d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) - d.Body = query.Repository.Discussion.Body d.Comments = DiscussionCommentList{TotalCount: query.Repository.Discussion.Comments.TotalCount} for _, rg := range query.Repository.Discussion.ReactionGroups { @@ -396,12 +392,19 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc return &d, nil } -func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, limit int, after string, newest bool) (*Discussion, error) { // Build the comments field with first/last based on order. - // "oldest" uses first (chronological), "newest" uses last (reverse chronological). + // oldest uses first+after (chronological), newest uses last+before (reverse). commentDirection := "first" - if order == "newest" { + cursorDirection := "after" + if newest { commentDirection = "last" + cursorDirection = "before" + } + + cursorArg := "" + if after != "" { + cursorArg = fmt.Sprintf(`, %s: "%s"`, cursorDirection, after) } query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!) { @@ -426,8 +429,14 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co updatedAt closedAt locked - comments(%s: %d) { + comments(%s: %d%s) { totalCount + pageInfo { + endCursor + hasNextPage + startCursor + hasPreviousPage + } nodes { id url @@ -437,7 +446,7 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co isAnswer upvoteCount reactionGroups { content users { totalCount } } - replies(first: 4) { + replies(last: 4) { totalCount nodes { id @@ -454,7 +463,7 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co } } } - }`, commentDirection, commentLimit) + }`, commentDirection, limit, cursorArg) variables := map[string]interface{}{ "owner": repo.RepoOwner(), @@ -525,8 +534,14 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co ClosedAt time.Time `json:"closedAt"` Locked bool `json:"locked"` Comments struct { - TotalCount int `json:"totalCount"` - Nodes []commentJSON `json:"nodes"` + TotalCount int `json:"totalCount"` + PageInfo struct { + EndCursor string `json:"endCursor"` + HasNextPage bool `json:"hasNextPage"` + StartCursor string `json:"startCursor"` + HasPreviousPage bool `json:"hasPreviousPage"` + } `json:"pageInfo"` + Nodes []commentJSON `json:"nodes"` } `json:"comments"` } `json:"discussion"` } `json:"repository"` @@ -570,9 +585,9 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co ReactionGroups: mapReactions(c.ReactionGroups), } if c.Replies != nil { - dc.TotalReplies = c.Replies.TotalCount - for _, r := range c.Replies.Nodes { - dc.Replies = append(dc.Replies, DiscussionComment{ + replyComments := make([]DiscussionComment, len(c.Replies.Nodes)) + for i, r := range c.Replies.Nodes { + replyComments[i] = DiscussionComment{ ID: r.ID, URL: r.URL, Author: mapActor(r.Author), @@ -581,7 +596,12 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co IsAnswer: r.IsAnswer, UpvoteCount: r.UpvoteCount, ReactionGroups: mapReactions(r.ReactionGroups), - }) + } + } + dc.Replies = DiscussionCommentList{ + Comments: replyComments, + TotalCount: c.Replies.TotalCount, + Direction: DiscussionCommentListDirectionBackward, // Since we always fetch the last 4 replies } } return dc @@ -629,15 +649,32 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, co // When using "last" (newest order), the API returns items in chronological // order. Reverse them so the newest comment appears first. - if order == "newest" { - for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 { - comments[i], comments[j] = comments[j], comments[i] + 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 diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index a690f84b135..a3cec3c639a 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -30,7 +30,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) { // panic("mock out the GetByNumber method") // }, -// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +// 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) { @@ -80,7 +80,7 @@ type DiscussionClientMock struct { GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error) // GetWithCommentsFunc mocks the GetWithComments method. - GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) + 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) @@ -153,8 +153,10 @@ type DiscussionClientMock struct { Number int // CommentLimit is the commentLimit argument value. CommentLimit int - // Order is the order argument value. - Order string + // 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 { @@ -401,7 +403,7 @@ func (mock *DiscussionClientMock) GetByNumberCalls() []struct { } // GetWithComments calls GetWithCommentsFunc. -func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +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") } @@ -409,17 +411,19 @@ func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool }{ Repo: repo, Number: number, CommentLimit: commentLimit, - Order: order, + After: after, + Newest: newest, } mock.lockGetWithComments.Lock() mock.calls.GetWithComments = append(mock.calls.GetWithComments, callInfo) mock.lockGetWithComments.Unlock() - return mock.GetWithCommentsFunc(repo, number, commentLimit, order) + return mock.GetWithCommentsFunc(repo, number, commentLimit, after, newest) } // GetWithCommentsCalls gets all the calls that were made to GetWithComments. @@ -430,13 +434,15 @@ func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct { Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool } { var calls []struct { Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool } mock.lockGetWithComments.RLock() calls = mock.calls.GetWithComments diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 0fffa9a8dc7..a5affcb3ac8 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -82,10 +82,17 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { for i, c := range d.Comments.Comments { comments[i] = c.Export() } - data[f] = map[string]interface{}{ + 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 { @@ -171,14 +178,13 @@ type DiscussionComment struct { IsAnswer bool UpvoteCount int ReactionGroups []ReactionGroup - Replies []DiscussionComment - TotalReplies int + 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)) - for i, r := range c.Replies { + replies := make([]interface{}, len(c.Replies.Comments)) + for i, r := range c.Replies.Comments { replies[i] = r.Export() } reactions := make([]interface{}, len(c.ReactionGroups)) @@ -194,15 +200,27 @@ func (c DiscussionComment) Export() map[string]interface{} { "isAnswer": c.IsAnswer, "upvoteCount": c.UpvoteCount, "reactionGroups": reactions, - "replies": replies, - "totalReplies": c.TotalReplies, + "replies": map[string]interface{}{ + "totalCount": c.Replies.TotalCount, + "nodes": replies, + }, } } +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. diff --git a/pkg/cmd/discussion/shared/client.go b/pkg/cmd/discussion/shared/client.go index 96c3345e06c..d0f34e04b78 100644 --- a/pkg/cmd/discussion/shared/client.go +++ b/pkg/cmd/discussion/shared/client.go @@ -7,31 +7,6 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" ) -// DiscussionFields lists all field names available for --json output -// on discussion commands that return a full discussion (e.g. view). -var DiscussionFields = []string{ - "id", - "number", - "title", - "body", - "url", - "closed", - "state", - "stateReason", - "author", - "category", - "labels", - "answered", - "answerChosenAt", - "answerChosenBy", - "comments", - "reactionGroups", - "createdAt", - "updatedAt", - "closedAt", - "locked", -} - // 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. diff --git a/pkg/cmd/discussion/shared/display.go b/pkg/cmd/discussion/shared/display.go deleted file mode 100644 index ea866fcb2a8..00000000000 --- a/pkg/cmd/discussion/shared/display.go +++ /dev/null @@ -1,35 +0,0 @@ -package shared - -import ( - "fmt" - "strings" - - "github.com/cli/cli/v2/pkg/cmd/discussion/client" -) - -var reactionEmoji = map[string]string{ - "THUMBS_UP": "\U0001f44d", - "THUMBS_DOWN": "\U0001f44e", - "LAUGH": "\U0001f604", - "HOORAY": "\U0001f389", - "CONFUSED": "\U0001f615", - "HEART": "\u2764\ufe0f", - "ROCKET": "\U0001f680", - "EYES": "\U0001f440", -} - -// ReactionGroupList formats reaction groups for display. -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, " • ") -} diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index ca94b238e0f..b114aaaa218 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -19,6 +19,55 @@ import ( "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 @@ -29,6 +78,8 @@ type ViewOptions struct { DiscussionNumber int WebMode bool Comments bool + Limit int + After string Order string Exporter cmdutil.Exporter Now func() time.Time @@ -50,6 +101,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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--web%[1]s flag, open the discussion in a web browser instead. `, "`"), @@ -64,7 +116,13 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman $ gh discussion view 123 --comments # View with newest comments first - $ gh discussion view 123 --comments --order newest + $ 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 # Open in browser $ gh discussion view 123 --web @@ -74,6 +132,15 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if cmd.Flags().Changed("order") && !opts.Comments { return cmdutil.FlagErrorf("--order requires --comments") } + if cmd.Flags().Changed("limit") && !opts.Comments { + return cmdutil.FlagErrorf("--limit requires --comments") + } + if cmd.Flags().Changed("after") && !opts.Comments { + return cmdutil.FlagErrorf("--after requires --comments") + } + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { @@ -100,8 +167,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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") - cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "oldest", []string{"oldest", "newest"}, "Order of comments") - cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields) + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments to fetch") + cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of comments") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of comments") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionFields) return cmd } @@ -130,7 +199,7 @@ func viewRun(opts *ViewOptions) error { var discussion *client.Discussion if opts.Comments { - discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, 30, opts.Order) + discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, opts.Limit, opts.After, opts.Order == "newest") } else { discussion, err = c.GetByNumber(repo, opts.DiscussionNumber) } @@ -209,7 +278,7 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { } fmt.Fprintf(out, "\n%s\n", md) - if reactions := shared.ReactionGroupList(d.ReactionGroups); reactions != "" { + if reactions := reactionGroupList(d.ReactionGroups); reactions != "" { fmt.Fprintln(out, reactions) fmt.Fprintln(out) } @@ -226,7 +295,19 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error { } if shown := len(d.Comments.Comments); shown < d.Comments.TotalCount { - fmt.Fprintf(out, cs.Muted(" And %d more comments\n"), d.Comments.TotalCount-shown) + 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) } } @@ -247,6 +328,9 @@ func printRawView(out io.Writer, d *client.Discussion, showComments bool) error 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, "--") @@ -288,20 +372,26 @@ func printHumanComment(opts *ViewOptions, out io.Writer, c client.DiscussionComm fmt.Fprint(out, md) } - if reactions := shared.ReactionGroupList(c.ReactionGroups); reactions != "" { + if reactions := reactionGroupList(c.ReactionGroups); reactions != "" { fmt.Fprintf(out, "%s%s\n", indent, reactions) } fmt.Fprintln(out) - for _, reply := range c.Replies { + for _, reply := range c.Replies.Comments { if err := printHumanComment(opts, out, reply, indent+" "); err != nil { return err } } - if shown := len(c.Replies); shown < c.TotalReplies { - fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d more replies", c.TotalReplies-shown))) + 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 @@ -321,7 +411,7 @@ func printRawComment(out io.Writer, c client.DiscussionComment, indent string) { } fmt.Fprintln(out) - for _, reply := range c.Replies { + for _, reply := range c.Replies.Comments { printRawComment(out, reply, indent+" ") } } diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 6e90d131cbc..92a3846a614 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -8,7 +8,6 @@ import ( "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/cmd/discussion/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -192,7 +191,7 @@ func TestViewRun_json(t *testing.T) { } exporter := cmdutil.NewJSONExporter() - exporter.SetFields(shared.DiscussionFields) + exporter.SetFields(discussionFields) opts := &ViewOptions{ IO: ios, @@ -367,16 +366,18 @@ func testDiscussionWithComments() *client.Discussion { ReactionGroups: []client.ReactionGroup{ {Content: "THUMBS_UP", TotalCount: 3}, }, - Replies: []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), + 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), + }, }, }, - TotalReplies: 5, }, { ID: "C_2", @@ -397,9 +398,9 @@ func TestViewRun_comments_tty(t *testing.T) { d := testDiscussionWithComments() mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { assert.Equal(t, 30, commentLimit) - assert.Equal(t, "oldest", order) + assert.Equal(t, false, newest) return d, nil }, } @@ -414,6 +415,7 @@ func TestViewRun_comments_tty(t *testing.T) { }, DiscussionNumber: 123, Comments: true, + Limit: 30, Order: "oldest", Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, } @@ -439,7 +441,7 @@ func TestViewRun_comments_nontty(t *testing.T) { d := testDiscussionWithComments() mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { return d, nil }, } @@ -454,6 +456,7 @@ func TestViewRun_comments_nontty(t *testing.T) { }, DiscussionNumber: 123, Comments: true, + Limit: 30, Order: "oldest", Now: time.Now, } @@ -475,13 +478,13 @@ func TestViewRun_comments_json(t *testing.T) { d := testDiscussionWithComments() mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*client.Discussion, error) { + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { return d, nil }, } exporter := cmdutil.NewJSONExporter() - exporter.SetFields(shared.DiscussionFields) + exporter.SetFields(discussionFields) opts := &ViewOptions{ IO: ios, @@ -493,6 +496,7 @@ func TestViewRun_comments_json(t *testing.T) { }, DiscussionNumber: 123, Comments: true, + Limit: 30, Order: "oldest", Exporter: exporter, Now: time.Now, @@ -559,3 +563,220 @@ func TestViewRun_noComments_usesGetByNumber(t *testing.T) { assert.Equal(t, 1, len(mock.GetByNumberCalls())) assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) } + +func TestNewCmdView_limitWithoutComments(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{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--limit", "10"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--limit requires --comments") +} + +func TestNewCmdView_afterWithoutComments(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{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--after", "CURSOR_ABC"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--after requires --comments") +} + +func TestNewCmdView_invalidLimit(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{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--comments", "--limit", "0"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid limit") +} + +func TestViewRun_commentsWithPagination_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_123" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, 10, commentLimit) + assert.Equal(t, "CURSOR_ABC", after) + assert.Equal(t, false, newest) + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 10, + After: "CURSOR_ABC", + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "To see more comments, pass: --after NEXT_CURSOR_123") +} + +func TestViewRun_commentsWithPagination_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_456" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "next:\tNEXT_CURSOR_456") +} + +func TestViewRun_commentsWithPagination_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + d.Comments.Cursor = "PREV_CURSOR" + d.Comments.NextCursor = "NEXT_CURSOR_789" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(discussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"cursor":"PREV_CURSOR"`) + assert.Contains(t, out, `"next":"NEXT_CURSOR_789"`) +} + +func TestViewRun_noPaginationCursor_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.NotContains(t, out, "--after") +} From 9f3a31ddb36c5dfcce5d7f6159bb2e8f5c0c6302 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 25 Apr 2026 12:19:04 -0500 Subject: [PATCH 41/81] fix(discussion view): use GraphQL variables for cursor, fix --json comments Fix two issues in the discussion view command: 1. GraphQL injection via cursor interpolation: The --after cursor value was interpolated directly into the raw GraphQL query string using fmt.Sprintf, which is unsafe since cursor values come from user input. Now uses GraphQL variables ($cursor: String) instead, matching the pattern used by issue list, pr list, and other commands. 2. Incomplete --json comments output: Running `gh discussion view N --json comments` silently returned only totalCount with no comment nodes, because the data fetch was gated solely on the --comments flag. Now checks if the JSON exporter requests the comments field and fetches full comment data accordingly, matching how issue view and pr view drive data loading from exporter fields. Also fixes example text that said "newest" but showed --order oldest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 27 ++++---- pkg/cmd/discussion/view/view.go | 33 ++++++++-- pkg/cmd/discussion/view/view_test.go | 84 +++++++++++++++++++++++- 3 files changed, 123 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index d83c9d35de0..83a0efc7c66 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -393,21 +393,16 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc } func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, limit int, after string, newest bool) (*Discussion, error) { - // Build the comments field with first/last based on order. - // oldest uses first+after (chronological), newest uses last+before (reverse). - commentDirection := "first" - cursorDirection := "after" + // Use two static query shapes to avoid interpolating user input into the + // GraphQL document. The cursor is always passed as a variable. + var commentsArg string if newest { - commentDirection = "last" - cursorDirection = "before" - } - - cursorArg := "" - if after != "" { - cursorArg = fmt.Sprintf(`, %s: "%s"`, cursorDirection, after) + commentsArg = "last: $limit, before: $cursor" + } else { + commentsArg = "first: $limit, after: $cursor" } - query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!) { + query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!, $limit: Int!, $cursor: String) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussion(number: $number) { @@ -429,7 +424,7 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, li updatedAt closedAt locked - comments(%s: %d%s) { + comments(%s) { totalCount pageInfo { endCursor @@ -463,12 +458,16 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, li } } } - }`, commentDirection, limit, cursorArg) + }`, commentsArg) variables := map[string]interface{}{ "owner": repo.RepoOwner(), "name": repo.RepoName(), "number": number, + "limit": limit, + } + if after != "" { + variables["cursor"] = after } type actorJSON struct { diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index b114aaaa218..97220e0f2d7 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -116,7 +116,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman $ gh discussion view 123 --comments # View with newest comments first - $ gh discussion view 123 --comments --order oldest + $ gh discussion view 123 --comments --order newest # Limit to 10 comments $ gh discussion view 123 --comments --limit 10 @@ -129,13 +129,14 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if cmd.Flags().Changed("order") && !opts.Comments { + commentsMode := opts.Comments || (opts.Exporter != nil && exporterNeedsComments(opts.Exporter)) + if cmd.Flags().Changed("order") && !commentsMode { return cmdutil.FlagErrorf("--order requires --comments") } - if cmd.Flags().Changed("limit") && !opts.Comments { + if cmd.Flags().Changed("limit") && !commentsMode { return cmdutil.FlagErrorf("--limit requires --comments") } - if cmd.Flags().Changed("after") && !opts.Comments { + if cmd.Flags().Changed("after") && !commentsMode { return cmdutil.FlagErrorf("--after requires --comments") } if opts.Limit < 1 { @@ -175,6 +176,28 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } +// exporterNeedsComments returns true when the JSON exporter requests the comments field. +func exporterNeedsComments(exporter cmdutil.Exporter) bool { + for _, f := range exporter.Fields() { + if f == "comments" { + return true + } + } + return false +} + +// 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 { + if opts.Comments { + return true + } + if opts.Exporter != nil { + return exporterNeedsComments(opts.Exporter) + } + return false +} + func viewRun(opts *ViewOptions) error { repo, err := opts.BaseRepo() if err != nil { @@ -198,7 +221,7 @@ func viewRun(opts *ViewOptions) error { opts.IO.StartProgressIndicator() var discussion *client.Discussion - if opts.Comments { + 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) diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 92a3846a614..1ae739168f2 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -183,9 +183,9 @@ func TestViewRun_json(t *testing.T) { ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(false) - d := testDiscussion() + d := testDiscussionWithComments() mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { return d, nil }, } @@ -202,6 +202,8 @@ func TestViewRun_json(t *testing.T) { return mock, nil }, DiscussionNumber: 123, + Limit: 30, + Order: "newest", Exporter: exporter, Now: time.Now, } @@ -780,3 +782,81 @@ func TestViewRun_noPaginationCursor_tty(t *testing.T) { out := stdout.String() assert.NotContains(t, out, "--after") } + +func TestViewRun_jsonComments_usesGetWithComments(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"comments"}) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Limit: 30, + Order: "newest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + // --json comments should use GetWithComments even without --comments flag + assert.Equal(t, 0, len(mock.GetByNumberCalls())) + assert.Equal(t, 1, len(mock.GetWithCommentsCalls())) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"octocat"`) +} + +func TestViewRun_jsonWithoutComments_usesGetByNumber(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"title", "number"}) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + // --json title,number should NOT fetch comments + assert.Equal(t, 1, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) +} From 49a846aa1ac75d936dc913d72e12b3e768218a75 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 08:45:43 +0100 Subject: [PATCH 42/81] docs(discussion view): use non-default behaviour in example Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/view/view.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 97220e0f2d7..27da3f1e749 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -115,8 +115,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # View with comments $ gh discussion view 123 --comments - # View with newest comments first - $ gh discussion view 123 --comments --order newest + # View with oldest comments first + $ gh discussion view 123 --comments --order oldest # Limit to 10 comments $ gh discussion view 123 --comments --limit 10 From 72a6c98d3763f49009dc8f7c3c1ef7c5a020fdf7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 08:46:56 +0100 Subject: [PATCH 43/81] refactor(discussion view): simplify mode check Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/view/view.go | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 27da3f1e749..d20a666d291 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -129,7 +129,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - commentsMode := opts.Comments || (opts.Exporter != nil && exporterNeedsComments(opts.Exporter)) + commentsMode := needsComments(opts) if cmd.Flags().Changed("order") && !commentsMode { return cmdutil.FlagErrorf("--order requires --comments") } @@ -178,24 +178,13 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman // exporterNeedsComments returns true when the JSON exporter requests the comments field. func exporterNeedsComments(exporter cmdutil.Exporter) bool { - for _, f := range exporter.Fields() { - if f == "comments" { - return true - } - } - return false + 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 { - if opts.Comments { - return true - } - if opts.Exporter != nil { - return exporterNeedsComments(opts.Exporter) - } - return false + return opts.Comments || opts.Exporter != nil && exporterNeedsComments(opts.Exporter) } func viewRun(opts *ViewOptions) error { From 524a503e8650e309c9bd9df104e290659cf10034 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 09:09:21 +0100 Subject: [PATCH 44/81] refactor(discussion/client): use strongly-typed query for fetching comments Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 347 ++++++++--------------- 1 file changed, 121 insertions(+), 226 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 83a0efc7c66..71890682f5a 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -392,258 +392,153 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc return &d, nil } -func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, limit int, after string, newest bool) (*Discussion, error) { - // Use two static query shapes to avoid interpolating user input into the - // GraphQL document. The cursor is always passed as a variable. - var commentsArg string - if newest { - commentsArg = "last: $limit, before: $cursor" - } else { - commentsArg = "first: $limit, after: $cursor" - } - - query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!, $limit: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - hasDiscussionsEnabled - discussion(number: $number) { - id - number - title - body - url - closed - stateReason - author { login ... on User { id name } ... on Bot { id } } - category { id name slug emoji isAnswerable } - labels(first: 20) { nodes { id name color } } - isAnswered - answerChosenAt - answerChosenBy { login ... on User { id name } ... on Bot { id } } - reactionGroups { content users { totalCount } } - createdAt - updatedAt - closedAt - locked - comments(%s) { - totalCount - pageInfo { - endCursor - hasNextPage - startCursor - hasPreviousPage - } - nodes { - id - url - author { login ... on User { id name } ... on Bot { id } } - body - createdAt - isAnswer - upvoteCount - reactionGroups { content users { totalCount } } - replies(last: 4) { - totalCount - nodes { - id - url - author { login ... on User { id name } ... on Bot { id } } - body - createdAt - isAnswer - upvoteCount - reactionGroups { content users { totalCount } } - } - } - } +// 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 []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 } } } - }`, commentsArg) + } `graphql:"replies(last: 4)"` +} - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "name": repo.RepoName(), - "number": number, - "limit": limit, +// 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, } - if after != "" { - variables["cursor"] = after + + for _, rg := range n.ReactionGroups { + dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) } - type actorJSON struct { - Login string `json:"login"` - ID string `json:"id"` - Name string `json:"name"` + replyComments := make([]DiscussionComment, len(n.Replies.Nodes)) + for i, r := range n.Replies.Nodes { + rc := DiscussionComment{ + ID: r.ID, + URL: r.URL, + Author: mapActorFromListNode(r.Author), + Body: r.Body, + CreatedAt: r.CreatedAt, + IsAnswer: r.IsAnswer, + UpvoteCount: r.UpvoteCount, + } + for _, rg := range r.ReactionGroups { + rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + replyComments[i] = rc + } + dc.Replies = DiscussionCommentList{ + Comments: replyComments, + TotalCount: n.Replies.TotalCount, + Direction: DiscussionCommentListDirectionBackward, } - type reactionGroupJSON struct { - Content string `json:"content"` - Users struct { - TotalCount int `json:"totalCount"` - } `json:"users"` - } - - type commentJSON struct { - ID string `json:"id"` - URL string `json:"url"` - Author actorJSON `json:"author"` - Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` - IsAnswer bool `json:"isAnswer"` - UpvoteCount int `json:"upvoteCount"` - ReactionGroups []reactionGroupJSON `json:"reactionGroups"` - Replies *struct { - TotalCount int `json:"totalCount"` - Nodes []commentJSON `json:"nodes"` - } `json:"replies"` - } - - type response struct { + 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 `json:"hasDiscussionsEnabled"` - Discussion *struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` - Closed bool `json:"closed"` - StateReason string `json:"stateReason"` - Author actorJSON `json:"author"` - Category struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Emoji string `json:"emoji"` - IsAnswerable bool `json:"isAnswerable"` - } `json:"category"` - Labels struct { - Nodes []struct { - ID string `json:"id"` - Name string `json:"name"` - Color string `json:"color"` - } `json:"nodes"` - } `json:"labels"` - IsAnswered bool `json:"isAnswered"` - AnswerChosenAt time.Time `json:"answerChosenAt"` - AnswerChosenBy *actorJSON `json:"answerChosenBy"` - ReactionGroups []reactionGroupJSON `json:"reactionGroups"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - ClosedAt time.Time `json:"closedAt"` - Locked bool `json:"locked"` - Comments struct { - TotalCount int `json:"totalCount"` + HasDiscussionsEnabled bool + Discussion struct { + discussionListNode + Comments struct { + TotalCount int PageInfo struct { - EndCursor string `json:"endCursor"` - HasNextPage bool `json:"hasNextPage"` - StartCursor string `json:"startCursor"` - HasPreviousPage bool `json:"hasPreviousPage"` - } `json:"pageInfo"` - Nodes []commentJSON `json:"nodes"` - } `json:"comments"` - } `json:"discussion"` - } `json:"repository"` - } - - var data response - err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data) - if err != nil { - return nil, err - } - if !data.Repository.HasDiscussionsEnabled { - return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) - } - if data.Repository.Discussion == nil { - return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName()) + 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)"` } - src := data.Repository.Discussion - - mapActor := func(a actorJSON) DiscussionActor { - return DiscussionActor{ID: a.ID, Login: a.Login, Name: a.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), } - mapReactions := func(groups []reactionGroupJSON) []ReactionGroup { - out := make([]ReactionGroup, len(groups)) - for i, rg := range groups { - out[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} - } - return out - } - - mapComment := func(c commentJSON) DiscussionComment { - dc := DiscussionComment{ - ID: c.ID, - URL: c.URL, - Author: mapActor(c.Author), - Body: c.Body, - CreatedAt: c.CreatedAt, - IsAnswer: c.IsAnswer, - UpvoteCount: c.UpvoteCount, - ReactionGroups: mapReactions(c.ReactionGroups), + if newest { + variables["last"] = githubv4.Int(limit) + if after != "" { + variables["before"] = githubv4.String(after) } - if c.Replies != nil { - replyComments := make([]DiscussionComment, len(c.Replies.Nodes)) - for i, r := range c.Replies.Nodes { - replyComments[i] = DiscussionComment{ - ID: r.ID, - URL: r.URL, - Author: mapActor(r.Author), - Body: r.Body, - CreatedAt: r.CreatedAt, - IsAnswer: r.IsAnswer, - UpvoteCount: r.UpvoteCount, - ReactionGroups: mapReactions(r.ReactionGroups), - } - } - dc.Replies = DiscussionCommentList{ - Comments: replyComments, - TotalCount: c.Replies.TotalCount, - Direction: DiscussionCommentListDirectionBackward, // Since we always fetch the last 4 replies - } + } else { + variables["first"] = githubv4.Int(limit) + if after != "" { + variables["after"] = githubv4.String(after) } - return dc } - d := Discussion{ - ID: src.ID, - Number: src.Number, - Title: src.Title, - Body: src.Body, - URL: src.URL, - Closed: src.Closed, - StateReason: src.StateReason, - Author: mapActor(src.Author), - Category: DiscussionCategory{ - ID: src.Category.ID, - Name: src.Category.Name, - Slug: src.Category.Slug, - Emoji: src.Category.Emoji, - IsAnswerable: src.Category.IsAnswerable, - }, - Answered: src.IsAnswered, - AnswerChosenAt: src.AnswerChosenAt, - ReactionGroups: mapReactions(src.ReactionGroups), - CreatedAt: src.CreatedAt, - UpdatedAt: src.UpdatedAt, - ClosedAt: src.ClosedAt, - Locked: src.Locked, + err := c.gql.Query(repo.RepoHost(), "DiscussionWithComments", &query, variables) + if err != nil { + return nil, err } - - if src.AnswerChosenBy != nil { - a := mapActor(*src.AnswerChosenBy) - d.AnswerChosenBy = &a + if !query.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } - d.Labels = make([]DiscussionLabel, len(src.Labels.Nodes)) - for i, l := range src.Labels.Nodes { - d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} + 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] = mapComment(c) + comments[i] = mapCommentFromNode(c) } // When using "last" (newest order), the API returns items in chronological From 5946d1a2981a6dcb865d408c20dbcc0d4ec75198 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 09:13:36 +0100 Subject: [PATCH 45/81] chore(discussion/client): apply formatting Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 71890682f5a..b7fdf36b29d 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -396,7 +396,7 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc // including nested replies. type discussionCommentNode struct { ID string - URL string `graphql:"url"` + URL string `graphql:"url"` Author actorNode Body string CreatedAt time.Time @@ -412,7 +412,7 @@ type discussionCommentNode struct { TotalCount int Nodes []struct { ID string - URL string `graphql:"url"` + URL string `graphql:"url"` Author actorNode Body string CreatedAt time.Time From b090b4d2fbc9d3a903257f2f6bdc78bd50de27fb Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 10:57:45 +0100 Subject: [PATCH 46/81] feat(discussion/client): add GetCommentReplies with paginated reply fetching Extract discussionReplyNode and mapReplyFromNode as reusable types for reply nodes. Add GetCommentReplies to the DiscussionClient interface, implemented using a combined node(id:) and repository.discussion query since the Discussion type does not expose a comment(id:) field. Add ExportReply() for leaf reply nodes (no nested replies) and include cursor/next pagination fields in the comment Export() replies object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client.go | 1 + pkg/cmd/discussion/client/client_impl.go | 220 +++++++++++++++++++---- pkg/cmd/discussion/client/client_mock.go | 102 +++++++++-- pkg/cmd/discussion/client/types.go | 35 +++- 4 files changed, 308 insertions(+), 50 deletions(-) diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index 5c7f1bdc45e..7a774deeb90 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -13,6 +13,7 @@ type DiscussionClient interface { Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) + GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index b7fdf36b29d..88aa7f17037 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -392,6 +392,43 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc return &d, nil } +// discussionReplyNode is the GraphQL response shape for a reply to a discussion comment. +type discussionReplyNode struct { + ID string + URL string `graphql:"url"` + Author actorNode + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []struct { + Content string + Users struct { + TotalCount int + } + } +} + +// mapReplyFromNode converts a discussionReplyNode into the domain DiscussionComment type. +func mapReplyFromNode(n discussionReplyNode) DiscussionComment { + rc := DiscussionComment{ + ID: n.ID, + URL: n.URL, + Author: mapActorFromListNode(n.Author), + Body: n.Body, + CreatedAt: n.CreatedAt, + IsAnswer: n.IsAnswer, + UpvoteCount: n.UpvoteCount, + } + for _, rg := range n.ReactionGroups { + rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + return rc +} + // discussionCommentNode is the GraphQL response shape for a discussion comment // including nested replies. type discussionCommentNode struct { @@ -410,21 +447,7 @@ type discussionCommentNode struct { } Replies struct { TotalCount int - Nodes []struct { - ID string - URL string `graphql:"url"` - Author actorNode - Body string - CreatedAt time.Time - IsAnswer bool - UpvoteCount int - ReactionGroups []struct { - Content string - Users struct { - TotalCount int - } - } - } + Nodes []discussionReplyNode } `graphql:"replies(last: 4)"` } @@ -449,22 +472,7 @@ func mapCommentFromNode(n discussionCommentNode) DiscussionComment { replyComments := make([]DiscussionComment, len(n.Replies.Nodes)) for i, r := range n.Replies.Nodes { - rc := DiscussionComment{ - ID: r.ID, - URL: r.URL, - Author: mapActorFromListNode(r.Author), - Body: r.Body, - CreatedAt: r.CreatedAt, - IsAnswer: r.IsAnswer, - UpvoteCount: r.UpvoteCount, - } - for _, rg := range r.ReactionGroups { - rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{ - Content: rg.Content, - TotalCount: rg.Users.TotalCount, - }) - } - replyComments[i] = rc + replyComments[i] = mapReplyFromNode(r) } dc.Replies = DiscussionCommentList{ Comments: replyComments, @@ -574,6 +582,156 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, li return &d, nil } +// GetCommentReplies fetches a discussion and a single comment with its +// paginated replies. It uses the top-level node(id:) query for the comment +// because the Discussion type does not expose a comment(id:) field. +func (c *discussionClient) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { + var query struct { + Repository struct { + HasDiscussionsEnabled bool + Discussion struct { + discussionListNode + } `graphql:"discussion(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + Node *struct { + DiscussionComment struct { + ID string + URL string `graphql:"url"` + Author actorNode + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []struct { + Content string + Users struct { + TotalCount int + } + } + Replies struct { + TotalCount int + PageInfo struct { + EndCursor string + HasNextPage bool + StartCursor string + HasPreviousPage bool + } + Nodes []discussionReplyNode + } `graphql:"replies(first: $first, last: $last, after: $after, before: $before)"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $commentID)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(number), + "commentID": githubv4.ID(commentID), + "first": (*githubv4.Int)(nil), + "last": (*githubv4.Int)(nil), + "after": (*githubv4.String)(nil), + "before": (*githubv4.String)(nil), + } + + if newest { + variables["last"] = githubv4.Int(limit) + if after != "" { + variables["before"] = githubv4.String(after) + } + } else { + variables["first"] = githubv4.Int(limit) + if after != "" { + variables["after"] = githubv4.String(after) + } + } + + err := c.gql.Query(repo.RepoHost(), "DiscussionCommentReplies", &query, variables) + if err != nil { + return nil, err + } + if !query.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + // The query above should already error for an invalid node ID, but guard against nil. + if query.Node == nil { + return nil, fmt.Errorf("comment %s not found", commentID) + } + + src := query.Node.DiscussionComment + if src.ID == "" { + return nil, fmt.Errorf("node %s is not a discussion comment", commentID) + } + + d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) + + for _, rg := range query.Repository.Discussion.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + dc := DiscussionComment{ + ID: src.ID, + URL: src.URL, + Author: mapActorFromListNode(src.Author), + Body: src.Body, + CreatedAt: src.CreatedAt, + IsAnswer: src.IsAnswer, + UpvoteCount: src.UpvoteCount, + } + + for _, rg := range src.ReactionGroups { + dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + replies := make([]DiscussionComment, len(src.Replies.Nodes)) + for i, r := range src.Replies.Nodes { + replies[i] = mapReplyFromNode(r) + } + + // When using "last" (newest order), the API returns items in chronological + // order. Reverse them so the newest reply appears first. + if newest { + slices.Reverse(replies) + } + + nextCursor := "" + if newest { + if src.Replies.PageInfo.HasPreviousPage { + nextCursor = src.Replies.PageInfo.StartCursor + } + } else { + if src.Replies.PageInfo.HasNextPage { + nextCursor = src.Replies.PageInfo.EndCursor + } + } + + direction := DiscussionCommentListDirectionForward + if newest { + direction = DiscussionCommentListDirectionBackward + } + + dc.Replies = DiscussionCommentList{ + Comments: replies, + TotalCount: src.Replies.TotalCount, + Cursor: after, + NextCursor: nextCursor, + Direction: direction, + } + + d.Comments = DiscussionCommentList{ + Comments: []DiscussionComment{dc}, + TotalCount: 1, + } + + return &d, nil +} + func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { var query struct { Repository struct { diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index a3cec3c639a..eb71f20e88a 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -30,6 +30,9 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) { // panic("mock out the GetByNumber method") // }, +// GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { +// panic("mock out the GetCommentReplies method") +// }, // GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { // panic("mock out the GetWithComments method") // }, @@ -79,6 +82,9 @@ type DiscussionClientMock struct { // GetByNumberFunc mocks the GetByNumber method. GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error) + // GetCommentRepliesFunc mocks the GetCommentReplies method. + GetCommentRepliesFunc func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) + // GetWithCommentsFunc mocks the GetWithComments method. GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) @@ -145,6 +151,21 @@ type DiscussionClientMock struct { // Number is the number argument value. Number int } + // GetCommentReplies holds details about calls to the GetCommentReplies method. + GetCommentReplies []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Number is the number argument value. + Number int + // CommentID is the commentID argument value. + CommentID string + // Limit is the limit argument value. + Limit int + // After is the after argument value. + After string + // Newest is the newest argument value. + Newest bool + } // GetWithComments holds details about calls to the GetWithComments method. GetWithComments []struct { // Repo is the repo argument value. @@ -230,20 +251,21 @@ type DiscussionClientMock struct { Input UpdateDiscussionInput } } - lockAddComment sync.RWMutex - lockClose sync.RWMutex - lockCreate sync.RWMutex - lockGetByNumber sync.RWMutex - lockGetWithComments sync.RWMutex - lockList sync.RWMutex - lockListCategories sync.RWMutex - lockLock sync.RWMutex - lockMarkAnswer sync.RWMutex - lockReopen sync.RWMutex - lockSearch sync.RWMutex - lockUnlock sync.RWMutex - lockUnmarkAnswer sync.RWMutex - lockUpdate sync.RWMutex + lockAddComment sync.RWMutex + lockClose sync.RWMutex + lockCreate sync.RWMutex + lockGetByNumber sync.RWMutex + lockGetCommentReplies sync.RWMutex + lockGetWithComments sync.RWMutex + lockList sync.RWMutex + lockListCategories sync.RWMutex + lockLock sync.RWMutex + lockMarkAnswer sync.RWMutex + lockReopen sync.RWMutex + lockSearch sync.RWMutex + lockUnlock sync.RWMutex + lockUnmarkAnswer sync.RWMutex + lockUpdate sync.RWMutex } // AddComment calls AddCommentFunc. @@ -402,6 +424,58 @@ func (mock *DiscussionClientMock) GetByNumberCalls() []struct { return calls } +// GetCommentReplies calls GetCommentRepliesFunc. +func (mock *DiscussionClientMock) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { + if mock.GetCommentRepliesFunc == nil { + panic("DiscussionClientMock.GetCommentRepliesFunc: method is nil but DiscussionClient.GetCommentReplies was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool + }{ + Repo: repo, + Number: number, + CommentID: commentID, + Limit: limit, + After: after, + Newest: newest, + } + mock.lockGetCommentReplies.Lock() + mock.calls.GetCommentReplies = append(mock.calls.GetCommentReplies, callInfo) + mock.lockGetCommentReplies.Unlock() + return mock.GetCommentRepliesFunc(repo, number, commentID, limit, after, newest) +} + +// GetCommentRepliesCalls gets all the calls that were made to GetCommentReplies. +// Check the length with: +// +// len(mockedDiscussionClient.GetCommentRepliesCalls()) +func (mock *DiscussionClientMock) GetCommentRepliesCalls() []struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool +} { + var calls []struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool + } + mock.lockGetCommentReplies.RLock() + calls = mock.calls.GetCommentReplies + mock.lockGetCommentReplies.RUnlock() + return calls +} + // GetWithComments calls GetWithCommentsFunc. func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { if mock.GetWithCommentsFunc == nil { diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index a5affcb3ac8..1eaab532918 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -185,8 +185,37 @@ type DiscussionComment struct { func (c DiscussionComment) Export() map[string]interface{} { replies := make([]interface{}, len(c.Replies.Comments)) for i, r := range c.Replies.Comments { - replies[i] = r.Export() + replies[i] = r.ExportReply() } + reactions := make([]interface{}, len(c.ReactionGroups)) + for i, rg := range c.ReactionGroups { + reactions[i] = rg.Export() + } + repliesMap := map[string]interface{}{ + "totalCount": c.Replies.TotalCount, + "nodes": replies, + } + if c.Replies.Cursor != "" { + repliesMap["cursor"] = c.Replies.Cursor + } + if c.Replies.NextCursor != "" { + repliesMap["next"] = c.Replies.NextCursor + } + return map[string]interface{}{ + "id": c.ID, + "url": c.URL, + "author": c.Author.Export(), + "body": c.Body, + "createdAt": c.CreatedAt, + "isAnswer": c.IsAnswer, + "upvoteCount": c.UpvoteCount, + "reactionGroups": reactions, + "replies": repliesMap, + } +} + +// ExportReply returns a reply as a map for JSON output, without nested replies. +func (c DiscussionComment) ExportReply() map[string]interface{} { reactions := make([]interface{}, len(c.ReactionGroups)) for i, rg := range c.ReactionGroups { reactions[i] = rg.Export() @@ -200,10 +229,6 @@ func (c DiscussionComment) Export() map[string]interface{} { "isAnswer": c.IsAnswer, "upvoteCount": c.UpvoteCount, "reactionGroups": reactions, - "replies": map[string]interface{}{ - "totalCount": c.Replies.TotalCount, - "nodes": replies, - }, } } From 56db9ee6e93d102e6ec5c568a30f9eb84ef49140 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 10:58:06 +0100 Subject: [PATCH 47/81] feat(gh discussion view): add --replies flag for paginated reply viewing Add --replies flag to view paginated replies on a specific discussion comment. Mutually exclusive with --comments and --web. Works with --limit, --after, and --order for pagination control. Supports TTY, raw, and JSON output modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view.go | 97 ++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index d20a666d291..78a5b701ac3 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -78,6 +78,7 @@ type ViewOptions struct { DiscussionNumber int WebMode bool Comments bool + Replies string Limit int After string Order string @@ -103,6 +104,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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(` @@ -124,20 +129,34 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # 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) - if cmd.Flags().Changed("order") && !commentsMode { - return cmdutil.FlagErrorf("--order requires --comments") + + paginatedMode := commentsMode || repliesMode + if cmd.Flags().Changed("order") && !paginatedMode { + return cmdutil.FlagErrorf("--order requires --comments or --replies") } - if cmd.Flags().Changed("limit") && !commentsMode { - return cmdutil.FlagErrorf("--limit requires --comments") + if cmd.Flags().Changed("limit") && !paginatedMode { + return cmdutil.FlagErrorf("--limit requires --comments or --replies") } - if cmd.Flags().Changed("after") && !commentsMode { - return cmdutil.FlagErrorf("--after requires --comments") + 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) @@ -168,9 +187,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments to fetch") - cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of comments") - cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of 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 @@ -209,6 +229,29 @@ func viewRun(opts *ViewOptions) error { 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() + + 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") @@ -448,3 +491,39 @@ func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) strin } 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 +} From 59701725d0c4a2fa1b33110438df0ff41f4d9dc3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 27 Apr 2026 18:46:14 +0100 Subject: [PATCH 48/81] fix(discussion view): guard against empty comment slice in replies mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 78a5b701ac3..98b1d608d71 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -245,6 +245,9 @@ func viewRun(opts *ViewOptions) error { } 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) From 11130fd6bed281a99b554cb9ddc9d32eed48d984 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Mon, 27 Apr 2026 15:42:36 -0500 Subject: [PATCH 49/81] test(discussion view): add tests for view command client methods and --replies mode Add table-driven tests for: Client (client_impl_test.go): - TestGetByNumber: field mapping, discussions disabled - TestGetWithComments: field mapping, forward/backward pagination, reply reversal in newest mode, discussions disabled - TestGetCommentReplies: field mapping, forward/backward pagination, reply reversal, discussions disabled, nil node, wrong node type Command (view_test.go): - TestNewCmdView_repliesFlags: mutual exclusivity with --comments/--web, --order/--limit/--after require --comments or --replies, pagination flags work with --replies - TestViewRun_replies: TTY/non-TTY/JSON output, pagination hints, routing assertion (GetCommentReplies called, not GetByNumber or GetWithComments) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 575 ++++++++++++++++++ pkg/cmd/discussion/view/view_test.go | 285 +++++++++ 2 files changed, 860 insertions(+) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 0a63927989b..462d8491599 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1006,3 +1006,578 @@ func TestListCategories(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// GetByNumber +// --------------------------------------------------------------------------- + +// getByNumberResp builds a mock DiscussionMinimal JSON response. +func getByNumberResp(hasDiscussions bool, commentTotal int, node string) string { + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + } + } + } + `, hasDiscussions, wrapCommentTotal(node, commentTotal)) +} + +// wrapCommentTotal merges a `comments` block into a discussion JSON node. +func wrapCommentTotal(node string, total int) string { + // Insert "comments":{"totalCount": N} right before the closing brace. + trimmed := strings.TrimRight(node, " \t\n") + trimmed = trimmed[:len(trimmed)-1] // strip trailing } + return fmt.Sprintf(`%s, "comments": {"totalCount": %d}}`, trimmed, total) +} + +func TestGetByNumber(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps all fields", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := heredoc.Doc(` + { + "id": "D_1", + "number": 42, + "title": "Test Discussion", + "body": "This is a test", + "url": "https://github.com/OWNER/REPO/discussions/42", + "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": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + `) + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(getByNumberResp(true, 5, node)), + ) + }, + wantDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, "D_1", d.ID) + assert.Equal(t, 42, d.Number) + assert.Equal(t, "Test Discussion", d.Title) + assert.Equal(t, "This is a test", d.Body) + assert.Equal(t, "alice", d.Author.Login) + assert.Equal(t, 5, d.Comments.TotalCount) + require.Len(t, d.ReactionGroups, 1) + assert.Equal(t, "THUMBS_UP", d.ReactionGroups[0].Content) + assert.Equal(t, 3, d.ReactionGroups[0].TotalCount) + }, + }, + { + name: "discussions disabled", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(getByNumberResp(false, 0, node)), + ) + }, + 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) + 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) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GetWithComments +// --------------------------------------------------------------------------- + +// getWithCommentsResp builds a mock DiscussionWithComments JSON response. +func getWithCommentsResp(hasDiscussions bool, node string, commentNodes string, commentTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + } + } + } + `, hasDiscussions, wrapCommentsBlock(node, commentNodes, commentTotal, hasNext, hasPrev, endCursor, startCursor)) +} + +func wrapCommentsBlock(node string, commentNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { + trimmed := strings.TrimRight(node, " \t\n") + trimmed = trimmed[:len(trimmed)-1] + return fmt.Sprintf(`%s, "comments": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, + trimmed, total, endCursor, hasNext, startCursor, hasPrev, commentNodes) +} + +// commentNode builds a JSON comment node with nested replies. +func commentNode(id, login, body string, isAnswer bool, replyNodes string, replyTotal int) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": %t, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": %d, "nodes": [%s]} + } + `, id, id, login, body, isAnswer, replyTotal, replyNodes) +} + +// replyNode builds a JSON reply node. +func replyNode(id, login, body string) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#reply-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + `, id, id, login, body) +} + +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 + wantComments int + wantTotal int + wantCursor string + wantNext string + wantDirection DiscussionCommentListDirection + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps comments with replies", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reply := replyNode("R1", "hubot", "Thanks!") + comment := commentNode("C1", "octocat", "Main comment", true, reply, 1) + node := minimalNode("D_1", "Test Discussion") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + ) + }, + wantComments: 1, + wantTotal: 1, + wantDirection: DiscussionCommentListDirectionForward, + wantDisc: func(t *testing.T, d *Discussion) { + c := d.Comments.Comments[0] + assert.Equal(t, "C1", c.ID) + assert.Equal(t, "octocat", c.Author.Login) + assert.True(t, c.IsAnswer) + require.Len(t, c.Replies.Comments, 1) + assert.Equal(t, "R1", c.Replies.Comments[0].ID) + assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + }, + }, + { + name: "pagination forward", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + comment := commentNode("C1", "alice", "Hello", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 3, true, false, "CUR_B", "")), + ) + }, + wantComments: 1, + wantTotal: 3, + wantCursor: "CUR_A", + wantNext: "CUR_B", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "pagination backward newest", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + c1 := commentNode("C1", "alice", "First", false, "", 0) + c2 := commentNode("C2", "bob", "Second", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, c1+","+c2, 5, false, true, "", "CUR_Y")), + ) + }, + wantComments: 2, + wantTotal: 5, + wantCursor: "CUR_X", + wantNext: "CUR_Y", + wantDirection: DiscussionCommentListDirectionBackward, + wantDisc: func(t *testing.T, d *Discussion) { + // Newest mode reverses the order + assert.Equal(t, "C2", d.Comments.Comments[0].ID) + assert.Equal(t, "C1", d.Comments.Comments[1].ID) + }, + }, + { + name: "no more pages", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + comment := commentNode("C1", "alice", "Only one", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + ) + }, + wantComments: 1, + wantTotal: 1, + wantNext: "", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "discussions disabled", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(false, node, "", 0, false, false, "", "")), + ) + }, + 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) + 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) + assert.Len(t, d.Comments.Comments, tt.wantComments) + assert.Equal(t, tt.wantTotal, d.Comments.TotalCount) + assert.Equal(t, tt.wantCursor, d.Comments.Cursor) + assert.Equal(t, tt.wantNext, d.Comments.NextCursor) + assert.Equal(t, tt.wantDirection, d.Comments.Direction) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GetCommentReplies +// --------------------------------------------------------------------------- + +// getCommentRepliesResp builds a mock DiscussionCommentReplies JSON response. +// The shurcooL graphql library treats inline fragments as transparent — the +// comment fields are placed directly inside the "node" object, not nested +// under a "DiscussionComment" key. +func getCommentRepliesResp(hasDiscussions bool, discNode string, commentNode *string, replyNodes string, replyTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { + nodeBlock := "null" + if commentNode != nil { + nodeBlock = wrapRepliesBlock(*commentNode, replyNodes, replyTotal, hasNext, hasPrev, endCursor, startCursor) + } + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + }, + "node": %s + } + } + `, hasDiscussions, discNode, nodeBlock) +} + +func wrapRepliesBlock(commentJSON string, replyNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { + trimmed := strings.TrimRight(commentJSON, " \t\n") + trimmed = trimmed[:len(trimmed)-1] + return fmt.Sprintf(`%s, "replies": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, + trimmed, total, endCursor, hasNext, startCursor, hasPrev, replyNodes) +} + +// bareCommentNode builds a comment JSON node without replies (used for GetCommentReplies). +func bareCommentNode(id, login, body string, isAnswer bool) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": %t, + "upvoteCount": 2, + "reactionGroups": [{"content": "HEART", "users": {"totalCount": 1}}] + } + `, id, id, login, body, isAnswer) +} + +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 + wantReplies int + wantTotal int + wantCursor string + wantNext string + wantDirection DiscussionCommentListDirection + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps all fields", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test Discussion") + comment := bareCommentNode("DC_abc", "octocat", "Top-level comment", true) + reply := replyNode("R1", "hubot", "A reply") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, reply, 1, false, false, "", "")), + ) + }, + wantReplies: 1, + wantTotal: 1, + wantDirection: DiscussionCommentListDirectionForward, + wantDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, "Test Discussion", d.Title) + require.Len(t, d.Comments.Comments, 1) + c := d.Comments.Comments[0] + assert.Equal(t, "DC_abc", c.ID) + assert.Equal(t, "octocat", c.Author.Login) + assert.Equal(t, "Top-level comment", c.Body) + assert.True(t, c.IsAnswer) + assert.Equal(t, 2, c.UpvoteCount) + require.Len(t, c.ReactionGroups, 1) + assert.Equal(t, "HEART", c.ReactionGroups[0].Content) + require.Len(t, c.Replies.Comments, 1) + assert.Equal(t, "R1", c.Replies.Comments[0].ID) + assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + }, + }, + { + name: "pagination forward oldest", + commentID: "DC_abc", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Reply 1") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 3, true, false, "CUR_B", "")), + ) + }, + wantReplies: 1, + wantTotal: 3, + wantCursor: "CUR_A", + wantNext: "CUR_B", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "pagination backward newest reverses replies", + commentID: "DC_abc", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Older") + r2 := replyNode("R2", "carol", "Newer") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1+","+r2, 5, false, true, "", "CUR_Y")), + ) + }, + wantReplies: 2, + wantTotal: 5, + wantCursor: "CUR_X", + wantNext: "CUR_Y", + wantDirection: DiscussionCommentListDirectionBackward, + wantDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies.Comments + assert.Equal(t, "R2", replies[0].ID, "newest mode should reverse replies") + assert.Equal(t, "R1", replies[1].ID) + }, + }, + { + name: "no more pages", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Only reply") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 1, false, false, "", "")), + ) + }, + wantReplies: 1, + wantTotal: 1, + wantNext: "", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "discussions disabled", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(false, discNode, &comment, "", 0, false, false, "", "")), + ) + }, + wantErr: "discussions disabled", + }, + { + name: "node not found nil", + commentID: "DC_invalid", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, nil, "", 0, false, false, "", "")), + ) + }, + wantErr: "comment DC_invalid not found", + }, + { + name: "node is not a discussion comment", + commentID: "I_notacomment", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + // Return a node with an empty DiscussionComment (wrong type) + emptyComment := `{"id":"","url":"","author":{"__typename":"User","login":""},"body":"","createdAt":"0001-01-01T00:00:00Z","isAnswer":false,"upvoteCount":0,"reactionGroups":[]}` + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &emptyComment, "", 0, false, false, "", "")), + ) + }, + 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") + + comment := d.Comments.Comments[0] + assert.Len(t, comment.Replies.Comments, tt.wantReplies) + assert.Equal(t, tt.wantTotal, comment.Replies.TotalCount) + assert.Equal(t, tt.wantCursor, comment.Replies.Cursor) + assert.Equal(t, tt.wantNext, comment.Replies.NextCursor) + assert.Equal(t, tt.wantDirection, comment.Replies.Direction) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 1ae739168f2..3f5e0348c02 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -860,3 +860,288 @@ func TestViewRun_jsonWithoutComments_usesGetByNumber(t *testing.T) { assert.Equal(t, 1, len(mock.GetByNumberCalls())) assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) } + +// --------------------------------------------------------------------------- +// --replies flag validation +// --------------------------------------------------------------------------- + +func TestNewCmdView_repliesFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "replies with comments is mutually exclusive", + args: []string{"123", "--replies", "DC_abc", "--comments"}, + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "replies with web is mutually exclusive", + args: []string{"123", "--replies", "DC_abc", "--web"}, + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "order requires comments or replies", + args: []string{"123", "--order", "newest"}, + wantErr: "--order requires --comments or --replies", + }, + { + name: "limit requires comments or replies", + args: []string{"123", "--limit", "5"}, + wantErr: "--limit requires --comments or --replies", + }, + { + name: "after requires comments or replies", + args: []string{"123", "--after", "CURSOR"}, + wantErr: "--after requires --comments or --replies", + }, + { + name: "order works with replies", + args: []string{"123", "--replies", "DC_abc", "--order", "oldest"}, + }, + { + name: "limit works with replies", + args: []string{"123", "--replies", "DC_abc", "--limit", "10"}, + }, + { + name: "after works with replies", + args: []string{"123", "--replies", "DC_abc", "--after", "CURSOR"}, + }, + } + + 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{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs(tt.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +// --------------------------------------------------------------------------- +// --replies viewRun tests (table-driven) +// --------------------------------------------------------------------------- + +func testDiscussionWithReplies(nextCursor string) *client.Discussion { + d := testDiscussion() + 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 TestViewRun_replies(t *testing.T) { + tests := []struct { + name string + tty bool + replies string + limit int + after string + order string + exporter cmdutil.Exporter + nextCursor string + wantContains []string + wantExcludes []string + wantClient func(*testing.T, *client.DiscussionClientMock) + }{ + { + name: "tty renders comment and replies", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + wantContains: []string{ + "octocat", + "This is the parent comment", + "✓ Answer", + "hubot", + "First reply", + "monalisa", + "Second reply", + }, + }, + { + name: "tty shows pagination hint", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + nextCursor: "NEXT_CUR", + wantContains: []string{ + "--after NEXT_CUR", + }, + }, + { + name: "tty no pagination hint when no next cursor", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + wantExcludes: []string{ + "--after", + }, + }, + { + name: "nontty raw output", + tty: false, + replies: "DC_abc", + limit: 30, + order: "oldest", + wantContains: []string{ + "comment:\toctocat\t", + "answer", + "replies:\t2", + "This is the parent comment", + "hubot", + "First reply", + }, + }, + { + name: "nontty shows next cursor", + tty: false, + replies: "DC_abc", + limit: 30, + order: "oldest", + nextCursor: "NEXT_CUR_456", + wantContains: []string{ + "next:\tNEXT_CUR_456", + }, + }, + { + name: "json output", + tty: false, + replies: "DC_abc", + limit: 30, + order: "newest", + exporter: func() cmdutil.Exporter { + e := cmdutil.NewJSONExporter() + e.SetFields(discussionFields) + return e + }(), + wantContains: []string{ + `"totalCount"`, + `"isAnswer":true`, + `"octocat"`, + }, + }, + { + name: "routes to GetCommentReplies only", + tty: false, + replies: "DC_abc", + limit: 10, + after: "CUR_A", + order: "oldest", + wantClient: func(t *testing.T, mock *client.DiscussionClientMock) { + require.Equal(t, 1, len(mock.GetCommentRepliesCalls())) + assert.Equal(t, 0, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) + + call := mock.GetCommentRepliesCalls()[0] + assert.Equal(t, "DC_abc", call.CommentID) + assert.Equal(t, 10, call.Limit) + assert.Equal(t, "CUR_A", call.After) + assert.Equal(t, false, call.Newest) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + + d := testDiscussionWithReplies(tt.nextCursor) + mock := &client.DiscussionClientMock{ + GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Replies: tt.replies, + Limit: tt.limit, + After: tt.after, + Order: tt.order, + Exporter: tt.exporter, + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + for _, s := range tt.wantContains { + assert.Contains(t, out, s) + } + for _, s := range tt.wantExcludes { + assert.NotContains(t, out, s) + } + if tt.wantClient != nil { + tt.wantClient(t, mock) + } + }) + } +} From 45bd958f750409a6ef3bd07179a3be8de3858520 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 09:30:10 +0100 Subject: [PATCH 50/81] test(discussion/client): improve GetByNumber test coverage Inline mock JSON responses, use non-default values for all fields to verify mapping, add repo-not-found test case, and match real API behaviour for discussions-disabled response (null discussion with NOT_FOUND error). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 174 +++++++++++------- 1 file changed, 105 insertions(+), 69 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 462d8491599..aad03047f0a 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1007,93 +1007,130 @@ func TestListCategories(t *testing.T) { } } -// --------------------------------------------------------------------------- -// GetByNumber -// --------------------------------------------------------------------------- - -// getByNumberResp builds a mock DiscussionMinimal JSON response. -func getByNumberResp(hasDiscussions bool, commentTotal int, node string) string { - return heredoc.Docf(` - { - "data": { - "repository": { - "hasDiscussionsEnabled": %t, - "discussion": %s - } - } - } - `, hasDiscussions, wrapCommentTotal(node, commentTotal)) -} - -// wrapCommentTotal merges a `comments` block into a discussion JSON node. -func wrapCommentTotal(node string, total int) string { - // Insert "comments":{"totalCount": N} right before the closing brace. - trimmed := strings.TrimRight(node, " \t\n") - trimmed = trimmed[:len(trimmed)-1] // strip trailing } - return fmt.Sprintf(`%s, "comments": {"totalCount": %d}}`, trimmed, total) -} - func TestGetByNumber(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { - name string - httpStubs func(*testing.T, *httpmock.Registry) - wantErr string - wantDisc func(*testing.T, *Discussion) + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + assertDisc *Discussion }{ { name: "maps all fields", httpStubs: func(t *testing.T, reg *httpmock.Registry) { - node := heredoc.Doc(` - { - "id": "D_1", - "number": 42, - "title": "Test Discussion", - "body": "This is a test", - "url": "https://github.com/OWNER/REPO/discussions/42", - "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": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-02T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", - "locked": false - } - `) reg.Register( httpmock.GraphQL(`query DiscussionMinimal\b`), - httpmock.StringResponse(getByNumberResp(true, 5, node)), + 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} + } + } + } + } + `)), ) }, - wantDisc: func(t *testing.T, d *Discussion) { - assert.Equal(t, "D_1", d.ID) - assert.Equal(t, 42, d.Number) - assert.Equal(t, "Test Discussion", d.Title) - assert.Equal(t, "This is a test", d.Body) - assert.Equal(t, "alice", d.Author.Login) - assert.Equal(t, 5, d.Comments.TotalCount) - require.Len(t, d.ReactionGroups, 1) - assert.Equal(t, "THUMBS_UP", d.ReactionGroups[0].Content) - assert.Equal(t, 3, d.ReactionGroups[0].TotalCount) + 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) { - node := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionMinimal\b`), - httpmock.StringResponse(getByNumberResp(false, 0, node)), + 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: "discussions disabled", + wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", }, } @@ -1117,9 +1154,8 @@ func TestGetByNumber(t *testing.T) { require.NoError(t, err) require.NotNil(t, d) - if tt.wantDisc != nil { - tt.wantDisc(t, d) - } + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + assert.Equal(t, tt.assertDisc, d) }) } } From 573f3f0a635f41e311b4b759c6db70450d013a1e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 09:31:36 +0100 Subject: [PATCH 51/81] test(discussion/test): use nil next page to match API behaviour Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index aad03047f0a..0910810c355 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -166,7 +166,7 @@ func TestList(t *testing.T) { "totalCount": 0, "pageInfo": { "hasNextPage": false, - "endCursor": "" + "endCursor": null }, "nodes": [] } From 19369d0444e064e1b4d47ba58880590d4586bf4d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 10:10:25 +0100 Subject: [PATCH 52/81] test(discussion/client): improve GetWithComments test coverage Inline all JSON response helpers (getWithCommentsResp, wrapCommentsBlock, commentNode) to avoid cross-test coupling. Add missing test cases for empty comments, first page newest reversal, multiple replies on a single comment, and repo not found. Populate the "maps comments with replies" case with non-zero field values and use a single assert.Equal for the full Discussion struct. Match real API error responses for discussions disabled and repo not found cases. Rename wantDisc to assertDisc across all client test functions and require it for non-error cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 677 +++++++++++++++--- 1 file changed, 573 insertions(+), 104 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 0910810c355..d04632d962b 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1164,44 +1164,6 @@ func TestGetByNumber(t *testing.T) { // GetWithComments // --------------------------------------------------------------------------- -// getWithCommentsResp builds a mock DiscussionWithComments JSON response. -func getWithCommentsResp(hasDiscussions bool, node string, commentNodes string, commentTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { - return heredoc.Docf(` - { - "data": { - "repository": { - "hasDiscussionsEnabled": %t, - "discussion": %s - } - } - } - `, hasDiscussions, wrapCommentsBlock(node, commentNodes, commentTotal, hasNext, hasPrev, endCursor, startCursor)) -} - -func wrapCommentsBlock(node string, commentNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { - trimmed := strings.TrimRight(node, " \t\n") - trimmed = trimmed[:len(trimmed)-1] - return fmt.Sprintf(`%s, "comments": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, - trimmed, total, endCursor, hasNext, startCursor, hasPrev, commentNodes) -} - -// commentNode builds a JSON comment node with nested replies. -func commentNode(id, login, body string, isAnswer bool, replyNodes string, replyTotal int) string { - return heredoc.Docf(` - { - "id": %q, - "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", - "author": {"__typename": "User", "login": %q}, - "body": %q, - "createdAt": "2025-01-01T00:00:00Z", - "isAnswer": %t, - "upvoteCount": 0, - "reactionGroups": [], - "replies": {"totalCount": %d, "nodes": [%s]} - } - `, id, id, login, body, isAnswer, replyTotal, replyNodes) -} - // replyNode builds a JSON reply node. func replyNode(id, login, body string) string { return heredoc.Docf(` @@ -1222,43 +1184,142 @@ 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 - wantComments int - wantTotal int - wantCursor string - wantNext string - wantDirection DiscussionCommentListDirection - wantDisc func(*testing.T, *Discussion) + 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) { - reply := replyNode("R1", "hubot", "Thanks!") - comment := commentNode("C1", "octocat", "Main comment", true, reply, 1) - node := minimalNode("D_1", "Test Discussion") reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), - httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + 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}}] + } + ] + } + } + ] + } + } + } + } + } + `)), ) }, - wantComments: 1, - wantTotal: 1, - wantDirection: DiscussionCommentListDirectionForward, - wantDisc: func(t *testing.T, d *Discussion) { - c := d.Comments.Comments[0] - assert.Equal(t, "C1", c.ID) - assert.Equal(t, "octocat", c.Author.Login) - assert.True(t, c.IsAnswer) - require.Len(t, c.Replies.Comments, 1) - assert.Equal(t, "R1", c.Replies.Comments[0].ID) - assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + 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) }, }, { @@ -1267,18 +1328,64 @@ func TestGetWithComments(t *testing.T) { after: "CUR_A", newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - comment := commentNode("C1", "alice", "Hello", false, "", 0) - node := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), - httpmock.StringResponse(getWithCommentsResp(true, node, comment, 3, true, false, "CUR_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": []} + } + ] + } + } + } + } + } + `)), ) }, - wantComments: 1, - wantTotal: 3, - wantCursor: "CUR_A", - wantNext: "CUR_B", - wantDirection: DiscussionCommentListDirectionForward, + 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", @@ -1286,23 +1393,76 @@ func TestGetWithComments(t *testing.T) { after: "CUR_X", newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - c1 := commentNode("C1", "alice", "First", false, "", 0) - c2 := commentNode("C2", "bob", "Second", false, "", 0) - node := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), - httpmock.StringResponse(getWithCommentsResp(true, node, c1+","+c2, 5, false, true, "", "CUR_Y")), + 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": []} + } + ] + } + } + } + } + } + `)), ) }, - wantComments: 2, - wantTotal: 5, - wantCursor: "CUR_X", - wantNext: "CUR_Y", - wantDirection: DiscussionCommentListDirectionBackward, - wantDisc: func(t *testing.T, d *Discussion) { - // Newest mode reverses the order - assert.Equal(t, "C2", d.Comments.Comments[0].ID) - assert.Equal(t, "C1", d.Comments.Comments[1].ID) + 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) }, }, { @@ -1310,30 +1470,345 @@ func TestGetWithComments(t *testing.T) { limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - comment := commentNode("C1", "alice", "Only one", false, "", 0) - node := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), - httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + 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": []} + } + ] + } + } + } + } + } + `)), ) }, - wantComments: 1, - wantTotal: 1, - wantNext: "", - wantDirection: DiscussionCommentListDirectionForward, + 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) { - node := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), - httpmock.StringResponse(getWithCommentsResp(false, node, "", 0, false, false, "", "")), + 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: "discussions disabled", + 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) + }, }, } @@ -1357,14 +1832,8 @@ func TestGetWithComments(t *testing.T) { require.NoError(t, err) require.NotNil(t, d) - assert.Len(t, d.Comments.Comments, tt.wantComments) - assert.Equal(t, tt.wantTotal, d.Comments.TotalCount) - assert.Equal(t, tt.wantCursor, d.Comments.Cursor) - assert.Equal(t, tt.wantNext, d.Comments.NextCursor) - assert.Equal(t, tt.wantDirection, d.Comments.Direction) - if tt.wantDisc != nil { - tt.wantDisc(t, d) - } + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + tt.assertDisc(t, d) }) } } From e263abfb2c4ae1038fb04821094a2e0d74d9f043 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 10:37:02 +0100 Subject: [PATCH 53/81] test(discussion/client): improve GetCommentReplies test coverage Inline all JSON response helpers (getCommentRepliesResp, wrapRepliesBlock, bareCommentNode, replyNode) to avoid cross-test coupling. Fix JSON response shape to place "node" as sibling of "repository" under "data", matching the real GraphQL query structure. Populate "maps all fields" with non-zero values and use a single assert.Equal for the full Discussion struct. Match real API error responses: discussions disabled returns NOT_FOUND on discussion, node not found returns NOT_FOUND with null node, wrong-type node returns empty object. Add missing test cases for repo not found and first page newest reversal. Move shared wantComments/wantTotal/wantCursor/wantNext/wantDirection fields into assertDisc callbacks for both TestGetWithComments and TestGetCommentReplies, giving each case full ownership of its assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 697 ++++++++++++++---- 1 file changed, 549 insertions(+), 148 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index d04632d962b..824de421924 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1164,22 +1164,6 @@ func TestGetByNumber(t *testing.T) { // GetWithComments // --------------------------------------------------------------------------- -// replyNode builds a JSON reply node. -func replyNode(id, login, body string) string { - return heredoc.Docf(` - { - "id": %q, - "url": "https://github.com/OWNER/REPO/discussions/1#reply-%s", - "author": {"__typename": "User", "login": %q}, - "body": %q, - "createdAt": "2025-02-01T00:00:00Z", - "isAnswer": false, - "upvoteCount": 0, - "reactionGroups": [] - } - `, id, id, login, body) -} - func TestGetWithComments(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") @@ -1842,68 +1826,18 @@ func TestGetWithComments(t *testing.T) { // GetCommentReplies // --------------------------------------------------------------------------- -// getCommentRepliesResp builds a mock DiscussionCommentReplies JSON response. -// The shurcooL graphql library treats inline fragments as transparent — the -// comment fields are placed directly inside the "node" object, not nested -// under a "DiscussionComment" key. -func getCommentRepliesResp(hasDiscussions bool, discNode string, commentNode *string, replyNodes string, replyTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { - nodeBlock := "null" - if commentNode != nil { - nodeBlock = wrapRepliesBlock(*commentNode, replyNodes, replyTotal, hasNext, hasPrev, endCursor, startCursor) - } - return heredoc.Docf(` - { - "data": { - "repository": { - "hasDiscussionsEnabled": %t, - "discussion": %s - }, - "node": %s - } - } - `, hasDiscussions, discNode, nodeBlock) -} - -func wrapRepliesBlock(commentJSON string, replyNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { - trimmed := strings.TrimRight(commentJSON, " \t\n") - trimmed = trimmed[:len(trimmed)-1] - return fmt.Sprintf(`%s, "replies": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, - trimmed, total, endCursor, hasNext, startCursor, hasPrev, replyNodes) -} - -// bareCommentNode builds a comment JSON node without replies (used for GetCommentReplies). -func bareCommentNode(id, login, body string, isAnswer bool) string { - return heredoc.Docf(` - { - "id": %q, - "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", - "author": {"__typename": "User", "login": %q}, - "body": %q, - "createdAt": "2025-01-01T00:00:00Z", - "isAnswer": %t, - "upvoteCount": 2, - "reactionGroups": [{"content": "HEART", "users": {"totalCount": 1}}] - } - `, id, id, login, body, isAnswer) -} - 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 - wantReplies int - wantTotal int - wantCursor string - wantNext string - wantDirection DiscussionCommentListDirection - wantDisc func(*testing.T, *Discussion) + 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", @@ -1911,31 +1845,123 @@ func TestGetCommentReplies(t *testing.T) { limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test Discussion") - comment := bareCommentNode("DC_abc", "octocat", "Top-level comment", true) - reply := replyNode("R1", "hubot", "A reply") reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, reply, 1, false, false, "", "")), + 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}}] + } + ] + } + } + } + } + `)), ) }, - wantReplies: 1, - wantTotal: 1, - wantDirection: DiscussionCommentListDirectionForward, - wantDisc: func(t *testing.T, d *Discussion) { - assert.Equal(t, "Test Discussion", d.Title) - require.Len(t, d.Comments.Comments, 1) - c := d.Comments.Comments[0] - assert.Equal(t, "DC_abc", c.ID) - assert.Equal(t, "octocat", c.Author.Login) - assert.Equal(t, "Top-level comment", c.Body) - assert.True(t, c.IsAnswer) - assert.Equal(t, 2, c.UpvoteCount) - require.Len(t, c.ReactionGroups, 1) - assert.Equal(t, "HEART", c.ReactionGroups[0].Content) - require.Len(t, c.Replies.Comments, 1) - assert.Equal(t, "R1", c.Replies.Comments[0].ID) - assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + 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) }, }, { @@ -1945,19 +1971,85 @@ func TestGetCommentReplies(t *testing.T) { after: "CUR_A", newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test") - comment := bareCommentNode("DC_abc", "alice", "Comment", false) - r1 := replyNode("R1", "bob", "Reply 1") reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 3, true, false, "CUR_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": [] + } + ] + } + } + } + } + `)), ) }, - wantReplies: 1, - wantTotal: 3, - wantCursor: "CUR_A", - wantNext: "CUR_B", - wantDirection: DiscussionCommentListDirectionForward, + 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", @@ -1966,24 +2058,170 @@ func TestGetCommentReplies(t *testing.T) { after: "CUR_X", newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test") - comment := bareCommentNode("DC_abc", "alice", "Comment", false) - r1 := replyNode("R1", "bob", "Older") - r2 := replyNode("R2", "carol", "Newer") reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1+","+r2, 5, false, true, "", "CUR_Y")), + 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": [] + } + ] + } + } + } + } + `)), ) }, - wantReplies: 2, - wantTotal: 5, - wantCursor: "CUR_X", - wantNext: "CUR_Y", - wantDirection: DiscussionCommentListDirectionBackward, - wantDisc: func(t *testing.T, d *Discussion) { - replies := d.Comments.Comments[0].Replies.Comments - assert.Equal(t, "R2", replies[0].ID, "newest mode should reverse replies") - assert.Equal(t, "R1", replies[1].ID) + 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) }, }, { @@ -1992,18 +2230,72 @@ func TestGetCommentReplies(t *testing.T) { limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test") - comment := bareCommentNode("DC_abc", "alice", "Comment", false) - r1 := replyNode("R1", "bob", "Only reply") reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 1, false, false, "", "")), + 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": [] + } + ] + } + } + } + } + `)), ) }, - wantReplies: 1, - wantTotal: 1, - wantNext: "", - wantDirection: DiscussionCommentListDirectionForward, + 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", @@ -2011,28 +2303,119 @@ func TestGetCommentReplies(t *testing.T) { limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test") - comment := bareCommentNode("DC_abc", "alice", "Comment", false) reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(false, discNode, &comment, "", 0, false, false, "", "")), + 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: "discussions disabled", + wantErr: "Could not resolve to a Discussion", }, { - name: "node not found nil", + 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) { - discNode := minimalNode("D_1", "Test") reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, nil, "", 0, false, false, "", "")), + 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: "comment DC_invalid not found", + wantErr: "Could not resolve to a node", }, { name: "node is not a discussion comment", @@ -2040,12 +2423,38 @@ func TestGetCommentReplies(t *testing.T) { limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { - discNode := minimalNode("D_1", "Test") - // Return a node with an empty DiscussionComment (wrong type) - emptyComment := `{"id":"","url":"","author":{"__typename":"User","login":""},"body":"","createdAt":"0001-01-01T00:00:00Z","isAnswer":false,"upvoteCount":0,"reactionGroups":[]}` reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), - httpmock.StringResponse(getCommentRepliesResp(true, discNode, &emptyComment, "", 0, false, false, "", "")), + 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", @@ -2073,16 +2482,8 @@ func TestGetCommentReplies(t *testing.T) { require.NoError(t, err) require.NotNil(t, d) require.Len(t, d.Comments.Comments, 1, "GetCommentReplies should return exactly one comment") - - comment := d.Comments.Comments[0] - assert.Len(t, comment.Replies.Comments, tt.wantReplies) - assert.Equal(t, tt.wantTotal, comment.Replies.TotalCount) - assert.Equal(t, tt.wantCursor, comment.Replies.Cursor) - assert.Equal(t, tt.wantNext, comment.Replies.NextCursor) - assert.Equal(t, tt.wantDirection, comment.Replies.Direction) - if tt.wantDisc != nil { - tt.wantDisc(t, d) - } + require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") + tt.assertDisc(t, d) }) } } From cf40f9293d2052671a79a29bacfa941c42238e25 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 10:41:54 +0100 Subject: [PATCH 54/81] chore(discussion/client): polish and cleanup tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 824de421924..16c0813a25a 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -105,10 +105,6 @@ func searchResp(hasNext bool, cursor string, count int, nodes string) string { `, count, hasNext, cursor, nodes) } -// --------------------------------------------------------------------------- -// List -// --------------------------------------------------------------------------- - func TestList(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") @@ -1160,10 +1156,6 @@ func TestGetByNumber(t *testing.T) { } } -// --------------------------------------------------------------------------- -// GetWithComments -// --------------------------------------------------------------------------- - func TestGetWithComments(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") @@ -1822,10 +1814,6 @@ func TestGetWithComments(t *testing.T) { } } -// --------------------------------------------------------------------------- -// GetCommentReplies -// --------------------------------------------------------------------------- - func TestGetCommentReplies(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") From 54ddede12f1ab8779e7d6ce69b4a1bfb4e92dc2d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 28 Apr 2026 11:03:55 +0100 Subject: [PATCH 55/81] test(discussion/view): consolidate NewCmdView flag parsing tests Replace scattered standalone test functions with a single table-driven TestNewCmdView covering all arg/flag parsing: number, hash, URL, web, comments, replies, limit, after, order, mutual exclusivity, and invalid inputs. Uses shlex.Split for arg parsing and asserts opts fields individually per case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view_test.go | 196 ++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 3f5e0348c02..71897a1737d 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -40,36 +41,182 @@ func testDiscussion() *client.Discussion { func TestNewCmdView(t *testing.T) { tests := []struct { - name string - args []string - wantNum int - wantErr string + name string + args string + wantErr string + wantOpts ViewOptions + wantRepo string }{ { - name: "number argument", - args: []string{"123"}, - wantNum: 123, + name: "number argument", + args: "123", + wantOpts: ViewOptions{ + DiscussionNumber: 123, + Limit: 30, + Order: "newest", + }, }, { - name: "hash number argument", - args: []string{"#456"}, - wantNum: 456, + name: "hash number argument", + args: "'#456'", + wantOpts: ViewOptions{ + DiscussionNumber: 456, + Limit: 30, + Order: "newest", + }, }, { - name: "URL argument", - args: []string{"https://github.com/OWNER/REPO/discussions/789"}, - wantNum: 789, + 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: []string{"not-a-number"}, + args: "not-a-number", wantErr: "invalid discussion argument", }, { name: "no arguments", - args: []string{}, + 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 { @@ -88,18 +235,31 @@ func TestNewCmdView(t *testing.T) { return nil }) - cmd.SetArgs(tt.args) + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - err := cmd.Execute() + _, 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.wantNum, gotOpts.DiscussionNumber) + 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) }) } } From 52f219a5aca05384e76bc243768214022248cbb3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 29 Apr 2026 13:05:21 +0100 Subject: [PATCH 56/81] test(discussion view): consolidate view run tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/view/view_test.go | 1710 ++++++++++++-------------- 1 file changed, 779 insertions(+), 931 deletions(-) diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 71897a1737d..0c975f3c278 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -2,41 +2,46 @@ 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 testDiscussion() *client.Discussion { - return &client.Discussion{ - ID: "D_123", - Number: 123, - Title: "How to authenticate with SSO?", - Body: "I need help with SSO authentication.", - 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 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) { @@ -264,257 +269,750 @@ func TestNewCmdView(t *testing.T) { } } -func TestViewRun_tty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) +func TestViewRun(t *testing.T) { + fixedNow := func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) } - d := testDiscussion() - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil - }, - } + 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 - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, - } + + about my interesting question + - err := viewRun(opts) - require.NoError(t, err) + 👍 5 • 🚀 2 - out := stdout.String() - assert.Contains(t, out, "How to authenticate with SSO?") - assert.Contains(t, out, "#123") - assert.Contains(t, out, "Q&A") - assert.Contains(t, out, "Asked by") - assert.Contains(t, out, "monalisa") - assert.Contains(t, out, "3 comments") - assert.Contains(t, out, "help-wanted") - assert.Contains(t, out, "View this discussion on GitHub") -} + 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 -func TestViewRun_nontty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) + + about my cool idea + - d := testDiscussion() - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil - }, - } + 👍 5 • 🚀 2 - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), }, - DiscussionNumber: 123, - Now: time.Now, - } + { + 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 - err := viewRun(opts) - require.NoError(t, err) + + about my interesting question + - out := stdout.String() - assert.Contains(t, out, "title:\tHow to authenticate with SSO?") - assert.Contains(t, out, "state:\tOPEN") - assert.Contains(t, out, "category:\tQ&A") - assert.Contains(t, out, "author:\tmonalisa") - assert.Contains(t, out, "labels:\thelp-wanted") - assert.Contains(t, out, "number:\t123") - assert.Contains(t, out, "--") - assert.Contains(t, out, "I need help with SSO authentication.") -} + 👍 5 • 🚀 2 -func TestViewRun_json(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) + Comments - d := testDiscussionWithComments() - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } + octocat commented less than a minute ago ✓ Answer - exporter := cmdutil.NewJSONExporter() - exporter.SetFields(discussionFields) + This is a comment - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Limit: 30, - Order: "newest", - Exporter: exporter, - Now: time.Now, - } + 👍 3 - err := viewRun(opts) - require.NoError(t, err) + hubot commented less than a minute ago + + Thanks! + + + And 4 more replies - out := stdout.String() - assert.Contains(t, out, `"title"`) - assert.Contains(t, out, `"number"`) - assert.Contains(t, out, "How to authenticate with SSO?") -} + monalisa commented less than a minute ago -func TestViewRun_web(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) + Another comment - b := &browser.Stub{} - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), }, - Browser: b, - DiscussionNumber: 123, - WebMode: true, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) + { + 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 - b.Verify(t, "https://github.com/OWNER/REPO/discussions/123") - assert.Contains(t, stderr.String(), "Opening") -} + + about my interesting question + -func TestViewRun_urlArg(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) + 👍 5 • 🚀 2 - d := testDiscussion() - d.URL = "https://github.com/OTHER/REPO/discussions/42" - d.Number = 42 + Comments - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - assert.Equal(t, "OTHER", repo.RepoOwner()) - assert.Equal(t, "REPO", repo.RepoName()) - assert.Equal(t, 42, number) - return d, nil - }, - } + octocat commented less than a minute ago ✓ Answer - f := &cmdutil.Factory{} - f.IOStreams = ios - f.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - } - f.Browser = &browser.Stub{} + This is a comment - var gotOpts *ViewOptions - cmd := NewCmdView(f, func(opts *ViewOptions) error { - gotOpts = opts - opts.Client = func() (client.DiscussionClient, error) { - return mock, nil - } - return viewRun(opts) - }) + 👍 3 - cmd.SetArgs([]string{"https://github.com/OTHER/REPO/discussions/42"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) + hubot commented less than a minute ago + + Thanks! + + + And 4 more replies - err := cmd.Execute() - require.NoError(t, err) - assert.Equal(t, 42, gotOpts.DiscussionNumber) + monalisa commented less than a minute ago - out := stdout.String() - assert.Contains(t, out, "number:\t42") -} + Another comment -func TestViewRun_answerable(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - d := testDiscussion() - d.Category.IsAnswerable = true + To see more comments, pass: --after NEXT_CURSOR_123 - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil + View this discussion on GitHub: https://github.com/OWNER/REPO/discussions/123 + `), }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + { + 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" + } + `)), }, - Client: func() (client.DiscussionClient, error) { - return mock, nil + { + 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 + } + } + `)), }, - DiscussionNumber: 123, - Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, - } - - err := viewRun(opts) - require.NoError(t, err) - assert.Contains(t, stdout.String(), "Asked by") -} + { + 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 -func TestViewRun_notAnswerable(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) + This is the parent comment - d := testDiscussion() - d.Category.Name = "General" - d.Category.IsAnswerable = false + 👍 3 - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil + 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 - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + 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 + + `), }, - Client: func() (client.DiscussionClient, error) { - return mock, nil + { + 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 + } + } + `)), }, - DiscussionNumber: 123, - Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, } - err := viewRun(opts) - require.NoError(t, err) + 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) - out := stdout.String() - assert.Contains(t, out, "Started by") - assert.NotContains(t, out, "Asked by") + 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 testDiscussionWithComments() *client.Discussion { - d := testDiscussion() +func exampleDiscussionWithComments() *client.Discussion { + d := exampleAnswerableDiscussion() d.Comments = client.DiscussionCommentList{ TotalCount: 2, Comments: []client.DiscussionComment{ @@ -553,558 +1051,8 @@ func testDiscussionWithComments() *client.Discussion { return d } -func TestViewRun_comments_tty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - d := testDiscussionWithComments() - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - assert.Equal(t, 30, commentLimit) - assert.Equal(t, false, newest) - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "Comments") - assert.Contains(t, out, "octocat") - assert.Contains(t, out, "✓ Answer") - assert.Contains(t, out, "This is a comment") - assert.Contains(t, out, "hubot") - assert.Contains(t, out, "Thanks!") - assert.Contains(t, out, "And 4 more replies") - assert.Contains(t, out, "monalisa") - assert.Contains(t, out, "Another comment") -} - -func TestViewRun_comments_nontty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussionWithComments() - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "comment:\toctocat\t") - assert.Contains(t, out, "answer") - assert.Contains(t, out, "This is a comment") - assert.Contains(t, out, "comment:\thubot\t") - assert.Contains(t, out, "comment:\tmonalisa\t") -} - -func TestViewRun_comments_json(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussionWithComments() - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - exporter := cmdutil.NewJSONExporter() - exporter.SetFields(discussionFields) - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Exporter: exporter, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, `"totalCount"`) - assert.Contains(t, out, `"isAnswer":true`) - assert.Contains(t, out, `"octocat"`) -} - -func TestNewCmdView_orderWithoutComments(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{} - - cmd := NewCmdView(f, func(opts *ViewOptions) error { - return nil - }) - - cmd.SetArgs([]string{"123", "--order", "newest"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "--order requires --comments") -} - -func TestViewRun_noComments_usesGetByNumber(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussion() - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: false, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - assert.Equal(t, 1, len(mock.GetByNumberCalls())) - assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) -} - -func TestNewCmdView_limitWithoutComments(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{} - - cmd := NewCmdView(f, func(opts *ViewOptions) error { - return nil - }) - - cmd.SetArgs([]string{"123", "--limit", "10"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "--limit requires --comments") -} - -func TestNewCmdView_afterWithoutComments(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{} - - cmd := NewCmdView(f, func(opts *ViewOptions) error { - return nil - }) - - cmd.SetArgs([]string{"123", "--after", "CURSOR_ABC"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "--after requires --comments") -} - -func TestNewCmdView_invalidLimit(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{} - - cmd := NewCmdView(f, func(opts *ViewOptions) error { - return nil - }) - - cmd.SetArgs([]string{"123", "--comments", "--limit", "0"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid limit") -} - -func TestViewRun_commentsWithPagination_tty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - d := testDiscussionWithComments() - d.Comments.NextCursor = "NEXT_CURSOR_123" - - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - assert.Equal(t, 10, commentLimit) - assert.Equal(t, "CURSOR_ABC", after) - assert.Equal(t, false, newest) - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 10, - After: "CURSOR_ABC", - Order: "oldest", - Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "To see more comments, pass: --after NEXT_CURSOR_123") -} - -func TestViewRun_commentsWithPagination_nontty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussionWithComments() - d.Comments.NextCursor = "NEXT_CURSOR_456" - - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "next:\tNEXT_CURSOR_456") -} - -func TestViewRun_commentsWithPagination_json(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussionWithComments() - d.Comments.Cursor = "PREV_CURSOR" - d.Comments.NextCursor = "NEXT_CURSOR_789" - - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - exporter := cmdutil.NewJSONExporter() - exporter.SetFields(discussionFields) - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Exporter: exporter, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, `"cursor":"PREV_CURSOR"`) - assert.Contains(t, out, `"next":"NEXT_CURSOR_789"`) -} - -func TestViewRun_noPaginationCursor_tty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - d := testDiscussionWithComments() - - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: true, - Limit: 30, - Order: "oldest", - Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.NotContains(t, out, "--after") -} - -func TestViewRun_jsonComments_usesGetWithComments(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussionWithComments() - mock := &client.DiscussionClientMock{ - GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - exporter := cmdutil.NewJSONExporter() - exporter.SetFields([]string{"comments"}) - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: false, - Limit: 30, - Order: "newest", - Exporter: exporter, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - // --json comments should use GetWithComments even without --comments flag - assert.Equal(t, 0, len(mock.GetByNumberCalls())) - assert.Equal(t, 1, len(mock.GetWithCommentsCalls())) - - out := stdout.String() - assert.Contains(t, out, `"totalCount"`) - assert.Contains(t, out, `"octocat"`) -} - -func TestViewRun_jsonWithoutComments_usesGetByNumber(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - d := testDiscussion() - mock := &client.DiscussionClientMock{ - GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { - return d, nil - }, - } - - exporter := cmdutil.NewJSONExporter() - exporter.SetFields([]string{"title", "number"}) - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Comments: false, - Exporter: exporter, - Now: time.Now, - } - - err := viewRun(opts) - require.NoError(t, err) - - // --json title,number should NOT fetch comments - assert.Equal(t, 1, len(mock.GetByNumberCalls())) - assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) -} - -// --------------------------------------------------------------------------- -// --replies flag validation -// --------------------------------------------------------------------------- - -func TestNewCmdView_repliesFlags(t *testing.T) { - tests := []struct { - name string - args []string - wantErr string - }{ - { - name: "replies with comments is mutually exclusive", - args: []string{"123", "--replies", "DC_abc", "--comments"}, - wantErr: "specify only one of --comments, --replies, or --web", - }, - { - name: "replies with web is mutually exclusive", - args: []string{"123", "--replies", "DC_abc", "--web"}, - wantErr: "specify only one of --comments, --replies, or --web", - }, - { - name: "order requires comments or replies", - args: []string{"123", "--order", "newest"}, - wantErr: "--order requires --comments or --replies", - }, - { - name: "limit requires comments or replies", - args: []string{"123", "--limit", "5"}, - wantErr: "--limit requires --comments or --replies", - }, - { - name: "after requires comments or replies", - args: []string{"123", "--after", "CURSOR"}, - wantErr: "--after requires --comments or --replies", - }, - { - name: "order works with replies", - args: []string{"123", "--replies", "DC_abc", "--order", "oldest"}, - }, - { - name: "limit works with replies", - args: []string{"123", "--replies", "DC_abc", "--limit", "10"}, - }, - { - name: "after works with replies", - args: []string{"123", "--replies", "DC_abc", "--after", "CURSOR"}, - }, - } - - 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{} - - cmd := NewCmdView(f, func(opts *ViewOptions) error { - return nil - }) - - cmd.SetArgs(tt.args) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -// --------------------------------------------------------------------------- -// --replies viewRun tests (table-driven) -// --------------------------------------------------------------------------- - -func testDiscussionWithReplies(nextCursor string) *client.Discussion { - d := testDiscussion() +func exampleDiscussionWithReplies(nextCursor string) *client.Discussion { + d := exampleAnswerableDiscussion() d.Comments = client.DiscussionCommentList{ TotalCount: 1, Comments: []client.DiscussionComment{ @@ -1144,164 +1092,64 @@ func testDiscussionWithReplies(nextCursor string) *client.Discussion { return d } -func TestViewRun_replies(t *testing.T) { - tests := []struct { - name string - tty bool - replies string - limit int - after string - order string - exporter cmdutil.Exporter - nextCursor string - wantContains []string - wantExcludes []string - wantClient func(*testing.T, *client.DiscussionClientMock) - }{ - { - name: "tty renders comment and replies", - tty: true, - replies: "DC_abc", - limit: 30, - order: "newest", - wantContains: []string{ - "octocat", - "This is the parent comment", - "✓ Answer", - "hubot", - "First reply", - "monalisa", - "Second reply", - }, - }, - { - name: "tty shows pagination hint", - tty: true, - replies: "DC_abc", - limit: 30, - order: "newest", - nextCursor: "NEXT_CUR", - wantContains: []string{ - "--after NEXT_CUR", - }, - }, - { - name: "tty no pagination hint when no next cursor", - tty: true, - replies: "DC_abc", - limit: 30, - order: "newest", - wantExcludes: []string{ - "--after", - }, - }, - { - name: "nontty raw output", - tty: false, - replies: "DC_abc", - limit: 30, - order: "oldest", - wantContains: []string{ - "comment:\toctocat\t", - "answer", - "replies:\t2", - "This is the parent comment", - "hubot", - "First reply", - }, - }, - { - name: "nontty shows next cursor", - tty: false, - replies: "DC_abc", - limit: 30, - order: "oldest", - nextCursor: "NEXT_CUR_456", - wantContains: []string{ - "next:\tNEXT_CUR_456", - }, +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, }, - { - name: "json output", - tty: false, - replies: "DC_abc", - limit: 30, - order: "newest", - exporter: func() cmdutil.Exporter { - e := cmdutil.NewJSONExporter() - e.SetFields(discussionFields) - return e - }(), - wantContains: []string{ - `"totalCount"`, - `"isAnswer":true`, - `"octocat"`, - }, + 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}, }, - { - name: "routes to GetCommentReplies only", - tty: false, - replies: "DC_abc", - limit: 10, - after: "CUR_A", - order: "oldest", - wantClient: func(t *testing.T, mock *client.DiscussionClientMock) { - require.Equal(t, 1, len(mock.GetCommentRepliesCalls())) - assert.Equal(t, 0, len(mock.GetByNumberCalls())) - assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + } +} - call := mock.GetCommentRepliesCalls()[0] - assert.Equal(t, "DC_abc", call.CommentID) - assert.Equal(t, 10, call.Limit) - assert.Equal(t, "CUR_A", call.After) - assert.Equal(t, false, call.Newest) - }, +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), } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(tt.tty) - ios.SetStderrTTY(tt.tty) - - d := testDiscussionWithReplies(tt.nextCursor) - mock := &client.DiscussionClientMock{ - GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { - return d, nil - }, - } - - opts := &ViewOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Client: func() (client.DiscussionClient, error) { - return mock, nil - }, - DiscussionNumber: 123, - Replies: tt.replies, - Limit: tt.limit, - After: tt.after, - Order: tt.order, - Exporter: tt.exporter, - Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, - } - - err := viewRun(opts) - require.NoError(t, err) - - out := stdout.String() - for _, s := range tt.wantContains { - assert.Contains(t, out, s) - } - for _, s := range tt.wantExcludes { - assert.NotContains(t, out, s) - } - if tt.wantClient != nil { - tt.wantClient(t, mock) - } - }) +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 } From 2e5623180ac95417abd1c5cf2cb11ec2fefac504 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 29 Apr 2026 12:36:03 -0500 Subject: [PATCH 57/81] feat(discussion/client): implement Create mutation with tests Implement the createDiscussion GraphQL mutation in the discussion client. - Add getRepositoryMeta helper to resolve repo node ID and check discussions-enabled flag before mutating - Skip repo lookup when CreateDiscussionInput.RepositoryID is provided - Reuse discussionListNode mapping for consistent field coverage - Table-driven tests: field mapping, pre-resolved repo ID, discussions disabled, repo not found, mutation error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 88 ++++++- pkg/cmd/discussion/client/client_impl_test.go | 244 ++++++++++++++++++ 2 files changed, 330 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 88aa7f17037..ead75da6e6a 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -775,8 +775,92 @@ func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCa return categories, nil } -func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +// 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 +} + +// createDiscussionGQLInput is the typed input for the createDiscussion GraphQL mutation. +type createDiscussionGQLInput struct { + RepositoryID githubv4.ID `json:"repositoryId"` + CategoryID githubv4.ID `json:"categoryId"` + Title githubv4.String `json:"title"` + Body githubv4.String `json:"body"` +} + +func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { + repoID := input.RepositoryID + if repoID == "" { + 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()) + } + repoID = meta.ID + } + + var mutation struct { + CreateDiscussion struct { + Discussion struct { + discussionListNode + Comments struct { + TotalCount int + } + } + } `graphql:"createDiscussion(input: $input)"` + } + + variables := map[string]interface{}{ + "input": createDiscussionGQLInput{ + RepositoryID: githubv4.ID(repoID), + 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 + } + + d := mapDiscussionFromListNode(mutation.CreateDiscussion.Discussion.discussionListNode) + d.Comments = DiscussionCommentList{TotalCount: mutation.CreateDiscussion.Discussion.Comments.TotalCount} + + for _, rg := range mutation.CreateDiscussion.Discussion.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + return &d, nil } func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) { diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 16c0813a25a..38b5934e3df 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2475,3 +2475,247 @@ func TestGetCommentReplies(t *testing.T) { }) } } + +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.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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + }, + 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: "skips repo lookup when RepositoryID provided", + input: CreateDiscussionInput{ + RepositoryID: "R_existing", + CategoryID: "CAT_1", + Title: "Pre-resolved", + Body: "Body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // No RepositoryMeta query should be made. + reg.Register( + httpmock.GraphQL(`mutation CreateDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "createDiscussion": { + "discussion": { + "id": "D_pre", + "number": 1, + "title": "Pre-resolved", + "body": "Body", + "url": "https://github.com/OWNER/REPO/discussions/1", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + }, + assertDisc: &Discussion{ + ID: "D_pre", + Number: 1, + Title: "Pre-resolved", + Body: "Body", + URL: "https://github.com/OWNER/REPO/discussions/1", + Author: DiscussionActor{Login: "alice"}, + Category: DiscussionCategory{ + ID: "CAT_1", + Name: "General", + Slug: "general", + }, + Labels: []DiscussionLabel{}, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 1, 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{ + RepositoryID: "R_1", + CategoryID: "BAD_CAT", + Title: "Test", + Body: "Body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + 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'.", + }, + } + + 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) + }) + } +} From d78703efaaf3f35062fd2bd7c325e12199f3c1c0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 1 May 2026 09:12:15 +0100 Subject: [PATCH 58/81] fix(discussion/client): polish Create implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 28 +++++++----------------- pkg/cmd/discussion/client/types.go | 7 +++--- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index ead75da6e6a..8b56948c943 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -805,25 +805,13 @@ func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repository }, nil } -// createDiscussionGQLInput is the typed input for the createDiscussion GraphQL mutation. -type createDiscussionGQLInput struct { - RepositoryID githubv4.ID `json:"repositoryId"` - CategoryID githubv4.ID `json:"categoryId"` - Title githubv4.String `json:"title"` - Body githubv4.String `json:"body"` -} - func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { - repoID := input.RepositoryID - if repoID == "" { - 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()) - } - repoID = meta.ID + 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 { @@ -838,8 +826,8 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI } variables := map[string]interface{}{ - "input": createDiscussionGQLInput{ - RepositoryID: githubv4.ID(repoID), + "input": githubv4.CreateDiscussionInput{ + RepositoryID: githubv4.ID(meta.ID), CategoryID: githubv4.ID(input.CategoryID), Title: githubv4.String(input.Title), Body: githubv4.String(input.Body), diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 1eaab532918..6e964bc9c56 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -327,10 +327,9 @@ type SearchFilters struct { // CreateDiscussionInput holds the parameters for creating a discussion. type CreateDiscussionInput struct { - RepositoryID string - CategoryID string - Title string - Body string + CategoryID string + Title string + Body string } // UpdateDiscussionInput holds optional parameters for updating a discussion. From d6b46f75d4e914935e8cbfad0bfb0c2c453c5641 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 1 May 2026 09:12:39 +0100 Subject: [PATCH 59/81] test(discussion/client): verify Create mutation variables and error paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 79 ++++--------------- 1 file changed, 14 insertions(+), 65 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 38b5934e3df..9687b5892d7 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2510,7 +2510,13 @@ func TestCreate(t *testing.T) { httpmock.StringResponse(repoMetaResp("R_1", true)), ) reg.Register( - httpmock.GraphQL(`mutation CreateDiscussion\b`), + 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": { @@ -2561,66 +2567,6 @@ func TestCreate(t *testing.T) { UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), }, }, - { - name: "skips repo lookup when RepositoryID provided", - input: CreateDiscussionInput{ - RepositoryID: "R_existing", - CategoryID: "CAT_1", - Title: "Pre-resolved", - Body: "Body", - }, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - // No RepositoryMeta query should be made. - reg.Register( - httpmock.GraphQL(`mutation CreateDiscussion\b`), - httpmock.StringResponse(heredoc.Doc(` - { - "data": { - "createDiscussion": { - "discussion": { - "id": "D_pre", - "number": 1, - "title": "Pre-resolved", - "body": "Body", - "url": "https://github.com/OWNER/REPO/discussions/1", - "closed": false, - "stateReason": "", - "isAnswered": false, - "answerChosenAt": "0001-01-01T00:00:00Z", - "author": {"__typename": "User", "login": "alice"}, - "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, - "answerChosenBy": null, - "labels": {"nodes": []}, - "reactionGroups": [], - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} - } - } - } - } - `)), - ) - }, - assertDisc: &Discussion{ - ID: "D_pre", - Number: 1, - Title: "Pre-resolved", - Body: "Body", - URL: "https://github.com/OWNER/REPO/discussions/1", - Author: DiscussionActor{Login: "alice"}, - Category: DiscussionCategory{ - ID: "CAT_1", - Name: "General", - Slug: "general", - }, - Labels: []DiscussionLabel{}, - CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - }, - }, { name: "discussions disabled", input: CreateDiscussionInput{ @@ -2667,12 +2613,15 @@ func TestCreate(t *testing.T) { { name: "mutation error", input: CreateDiscussionInput{ - RepositoryID: "R_1", - CategoryID: "BAD_CAT", - Title: "Test", - Body: "Body", + 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(` From fd06ad75565f57f2e45b9abb3b90d0f0f3d0dea9 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 1 May 2026 14:17:38 -0500 Subject: [PATCH 60/81] discussion create: add label support via two-step GraphQL - Add Labels []string field to CreateDiscussionInput - Implement resolveLabels helper: paginated RepositoryLabels query, case-insensitive match, error if any label not found - Implement addLabelsToDiscussion helper: calls addLabelsToLabelable mutation after createDiscussion - Wire label logic into Create: resolve labels, apply them, populate d.Labels from resolved values - Add three TestCreate cases: success with labels, label not found, mutation failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 99 ++++++++ pkg/cmd/discussion/client/client_impl_test.go | 223 ++++++++++++++++++ pkg/cmd/discussion/client/types.go | 1 + 3 files changed, 323 insertions(+) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 8b56948c943..6d71ab9ef95 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -805,6 +805,90 @@ func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repository }, nil } +// resolveLabels fetches all labels for a repository and matches the requested names +// case-insensitively. Returns an error if any requested label name is not found. +func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []string) ([]DiscussionLabel, error) { + if len(labelNames) == 0 { + return nil, nil + } + + 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)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + wanted := make(map[string]bool, len(labelNames)) + for _, n := range labelNames { + wanted[strings.ToLower(n)] = true + } + + found := make(map[string]DiscussionLabel, len(labelNames)) + for { + if err := c.gql.Query(repo.RepoHost(), "RepositoryLabels", &query, variables); err != nil { + return nil, err + } + for _, n := range query.Repository.Labels.Nodes { + if wanted[strings.ToLower(n.Name)] { + found[strings.ToLower(n.Name)] = 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) + } + + result := make([]DiscussionLabel, 0, len(labelNames)) + for _, name := range labelNames { + label, ok := found[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("label not found: %q", name) + } + result = append(result, label) + } + return result, nil +} + +// addLabelsToDiscussion applies labels to a discussion via the addLabelsToLabelable mutation. +func (c *discussionClient) addLabelsToDiscussion(repo ghrepo.Interface, discussionID string, labelIDs []string) error { + ids := make([]githubv4.ID, len(labelIDs)) + for i, id := range labelIDs { + ids[i] = githubv4.ID(id) + } + + var mutation struct { + AddLabelsToLabelable struct { + Typename string `graphql:"__typename"` + } `graphql:"addLabelsToLabelable(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.AddLabelsToLabelableInput{ + LabelableID: githubv4.ID(discussionID), + LabelIDs: ids, + }, + } + + return c.gql.Mutate(repo.RepoHost(), "AddLabelsToDiscussion", &mutation, variables) +} + func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { meta, err := c.getRepositoryMeta(repo) if err != nil { @@ -848,6 +932,21 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI }) } + if len(input.Labels) > 0 { + resolvedLabels, err := c.resolveLabels(repo, input.Labels) + if err != nil { + return nil, err + } + labelIDs := make([]string, len(resolvedLabels)) + for i, l := range resolvedLabels { + labelIDs[i] = l.ID + } + if err := c.addLabelsToDiscussion(repo, d.ID, labelIDs); err != nil { + return nil, err + } + d.Labels = resolvedLabels + } + return &d, nil } diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 9687b5892d7..66852d8b3c9 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2641,6 +2641,229 @@ func TestCreate(t *testing.T) { }, wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.", }, + { + name: "creates discussion with labels", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + Labels: []string{"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": "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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + ) + }, + 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"}}, + 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: "label not found returns error", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + Labels: []string{"nonexistent"}, + }, + 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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + }, + wantErr: `label not found: "nonexistent"`, + }, + { + name: "add labels mutation failure returns error", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + Labels: []string{"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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + 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 { diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 6e964bc9c56..d725ee92c2f 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -330,6 +330,7 @@ type CreateDiscussionInput struct { CategoryID string Title string Body string + Labels []string } // UpdateDiscussionInput holds optional parameters for updating a discussion. From 618dbf33f0d4d38f72d1b3edd162a5ccadff9e3c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 5 May 2026 12:46:33 +0100 Subject: [PATCH 61/81] fix(discussion/client): improve label resolution error handling - Break early from pagination when all wanted labels are found - Collect all missing labels and report them in a single error message - Guard missing-label check with len(found) != len(wanted) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 6d71ab9ef95..78108a2330c 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -849,19 +849,28 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str found[strings.ToLower(n.Name)] = DiscussionLabel{ID: n.ID, Name: n.Name, Color: n.Color} } } + if len(found) == len(wanted) { + break + } if !query.Repository.Labels.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) } + if len(found) != len(wanted) { + var missing []string + for _, name := range labelNames { + if _, ok := found[strings.ToLower(name)]; !ok { + missing = append(missing, name) + } + } + return nil, fmt.Errorf("labels not found: %s", strings.Join(missing, ", ")) + } + result := make([]DiscussionLabel, 0, len(labelNames)) for _, name := range labelNames { - label, ok := found[strings.ToLower(name)] - if !ok { - return nil, fmt.Errorf("label not found: %q", name) - } - result = append(result, label) + result = append(result, found[strings.ToLower(name)]) } return result, nil } From 5d917f1af20bbdc5512e0903f287f8a19dcb0d41 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 5 May 2026 12:46:53 +0100 Subject: [PATCH 62/81] test(discussion/client): add label pagination and early-break tests - Add "paginates labels across multiple pages" case (two pages, one label each) - Add "stops paginating labels when all found" case (early break verified via reg.Verify) - Update "creates discussion with labels" to two labels with variable assertions - Update "label not found" to verify all missing labels reported at once Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl_test.go | 218 +++++++++++++++++- 1 file changed, 212 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 66852d8b3c9..8ca5f52b2f7 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2642,12 +2642,12 @@ func TestCreate(t *testing.T) { wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.", }, { - name: "creates discussion with labels", + name: "paginates labels across multiple pages", input: CreateDiscussionInput{ CategoryID: "CAT_1", Title: "New Discussion", Body: "Discussion body", - Labels: []string{"bug"}, + Labels: []string{"bug", "enhancement"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -2674,7 +2674,7 @@ func TestCreate(t *testing.T) { "category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false}, "answerChosenBy": null, "labels": {"nodes": []}, - "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 0}}], + "reactionGroups": [], "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", @@ -2696,6 +2696,23 @@ func TestCreate(t *testing.T) { "nodes": [ {"id": "L_bug", "name": "bug", "color": "d73a4a"} ], + "pageInfo": {"hasNextPage": true, "endCursor": "LABEL_CUR_1"} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"} + ], "pageInfo": {"hasNextPage": false, "endCursor": ""} } } @@ -2721,7 +2738,196 @@ func TestCreate(t *testing.T) { Slug: "general", Emoji: ":speech_balloon:", }, - Labels: []DiscussionLabel{{ID: "L_bug", Name: "bug", Color: "d73a4a"}}, + 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), + }, + }, + { + name: "stops paginating labels when all found", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + Labels: []string{"bug", "enhancement"}, + }, + 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": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + // Register a single page that returns both labels but claims more pages exist. + // The code should stop paginating once all wanted labels are found. + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\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": true, "endCursor": "LABEL_CUR_999"} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + ) + }, + 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"}, + }, + 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: "creates discussion with labels", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + Labels: []string{"bug", "enhancement"}, + }, + 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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\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": ""} + } + } + } + } + `)), + ) + 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(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + ) + }, + 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), @@ -2733,7 +2939,7 @@ func TestCreate(t *testing.T) { CategoryID: "CAT_1", Title: "Test", Body: "Body", - Labels: []string{"nonexistent"}, + Labels: []string{"nonexistent", "also-missing"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -2788,7 +2994,7 @@ func TestCreate(t *testing.T) { `)), ) }, - wantErr: `label not found: "nonexistent"`, + wantErr: `labels not found: nonexistent, also-missing`, }, { name: "add labels mutation failure returns error", From e471f3f8f11c9dbd4c8a87b041e9917608221b1d Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 6 May 2026 14:52:07 -0500 Subject: [PATCH 63/81] feat: add gh discussion create command Implements the 'gh discussion create' CLI command, wiring up the already-merged Create client method. Supports: - --title/-t, --body/-b, --category/-c, --label/-l flags - Interactive prompting when TTY and required flags are missing - Non-TTY mode requiring all flags - TTY and non-TTY output formats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/create/create.go | 161 +++++++++++ pkg/cmd/discussion/create/create_test.go | 338 +++++++++++++++++++++++ pkg/cmd/discussion/discussion.go | 2 + 3 files changed, 501 insertions(+) create mode 100644 pkg/cmd/discussion/create/create.go create mode 100644 pkg/cmd/discussion/create/create_test.go diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go new file mode 100644 index 00000000000..8504c2c4cb8 --- /dev/null +++ b/pkg/cmd/discussion/create/create.go @@ -0,0 +1,161 @@ +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", + Long: heredoc.Doc(` + Create a new GitHub Discussion in a repository. + + With '--title' and '--category', a discussion is created non-interactively. + Omitting either flag triggers interactive prompts when connected to a terminal. + + The '--body' flag provides the discussion body. Without it you will be + prompted to enter one in your default editor. + `), + 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 runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + 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 + } + + categories, err := c.ListCategories(repo) + if err != nil { + return fmt.Errorf("fetching categories: %w", err) + } + + interactive := opts.IO.CanPrompt() + + if opts.Title == "" { + if !interactive { + return cmdutil.FlagErrorf("--title required when not running interactively") + } + opts.Title, err = opts.Prompter.Input("Discussion title", "") + if err != nil { + return err + } + } + if strings.TrimSpace(opts.Title) == "" { + return cmdutil.FlagErrorf("title cannot be blank") + } + + var category *client.DiscussionCategory + if opts.Category != "" { + category, err = shared.MatchCategory(opts.Category, categories) + if err != nil { + return err + } + } else { + if !interactive { + return cmdutil.FlagErrorf("--category required when not running interactively") + } + 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 == "" { + if !interactive { + return cmdutil.FlagErrorf("--body required when not running interactively") + } + opts.Body, err = opts.Prompter.MarkdownEditor("Discussion body", "", true) + if err != nil { + return err + } + } + + input := client.CreateDiscussionInput{ + CategoryID: category.ID, + Title: opts.Title, + Body: opts.Body, + Labels: opts.Labels, + } + + discussion, err := c.Create(repo, input) + if err != nil { + return fmt.Errorf("creating discussion: %w", err) + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Created discussion #%d: %s\n", + cs.SuccessIcon(), discussion.Number, discussion.URL) + } else { + 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..6b5ba12c119 --- /dev/null +++ b/pkg/cmd/discussion/create/create_test.go @@ -0,0 +1,338 @@ +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 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", + } +} + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + args string + wantOpts CreateOptions + wantErr string + }{ + { + name: "no flags", + args: "", + wantOpts: CreateOptions{}, + }, + { + name: "title flag", + args: "--title 'My question'", + wantOpts: CreateOptions{ + Title: "My question", + }, + }, + { + name: "all flags", + args: "--title 'My question' --body 'Details' --category 'Q&A' --label bug", + wantOpts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + Labels: []string{"bug"}, + }, + }, + { + name: "extra args", + args: "extra", + wantErr: "unknown argument", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var capturedOpts *CreateOptions + cmd := NewCmdCreate(f, func(opts *CreateOptions) error { + capturedOpts = 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, capturedOpts.Title) + assert.Equal(t, tt.wantOpts.Body, capturedOpts.Body) + assert.Equal(t, tt.wantOpts.Category, capturedOpts.Category) + assert.Equal(t, tt.wantOpts.Labels, capturedOpts.Labels) + }) + } +} + +func TestCreateRun_nonInteractive(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + wantErr string + wantOut string + setupMock func(*client.DiscussionClientMock) + }{ + { + name: "creates discussion successfully", + 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: "creates with label", + opts: CreateOptions{ + Title: "Feature request", + Body: "Details", + Category: "general", + Labels: []string{"enhancement"}, + }, + 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, []string{"enhancement"}, input.Labels) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "missing title returns error", + opts: CreateOptions{ + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--title required when not running interactively", + }, + { + name: "missing category returns error", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--category required when not running interactively", + }, + { + name: "missing body returns error", + opts: CreateOptions{ + Title: "My question", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--body required when not running interactively", + }, + { + name: "unknown category returns error", + 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: "ListCategories error propagates", + 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: "Create error propagates", + 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: "creating discussion: mutation failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + // non-interactive: no TTY + + 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 } + + 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 TestCreateRun_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + 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 + }, + } + + pm := &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) { + return "Some body text", nil + }, + } + + opts := &CreateOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Prompter: pm, + } + + err := createRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created discussion #5") + assert.Contains(t, stdout.String(), "https://github.com/OWNER/REPO/discussions/5") +} + +func TestCreateRun_tty_partialFlags(t *testing.T) { + // Title and body provided, category via prompt + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + 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 + }, + } + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // select Q&A + }, + } + + opts := &CreateOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Prompter: pm, + Title: "Pre-filled title", + Body: "Pre-filled body", + } + + err := createRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created discussion #5") +} diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index a547638953d..7e8d1f2527a 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -2,6 +2,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" + cmdCreate "github.com/cli/cli/v2/pkg/cmd/discussion/create" 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" @@ -34,6 +35,7 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmdutil.AddGroup(cmd, "General commands", + cmdCreate.NewCmdCreate(f, nil), cmdList.NewCmdList(f, nil), ) From a1fd235755e467781c5e189459fa046ff73bef7f Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Wed, 6 May 2026 17:29:12 -0500 Subject: [PATCH 64/81] fix: label atomicity, validation order, blankAllowed, help text - Resolve labels before creating discussion so a bad label name doesn't leave an orphaned discussion (atomicity fix) - Validate --title/--category/--body non-interactively before calling ListCategories to avoid an unnecessary network round-trip - Set blankAllowed=false so the markdown editor rejects empty bodies - Clarify help text: --body is required when not running interactively - Update tests to match new behavior; rename label-not-found test to make the atomicity guarantee explicit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 16 ++++-- pkg/cmd/discussion/client/client_impl_test.go | 36 ++----------- pkg/cmd/discussion/create/create.go | 25 ++++++--- pkg/cmd/discussion/create/create_test.go | 54 ++++++++++++------- 4 files changed, 67 insertions(+), 64 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 78108a2330c..5497eb95588 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -907,6 +907,16 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } + // Resolve labels before creating the discussion so that an unknown label + // name aborts without leaving a half-created discussion behind. + var resolvedLabels []DiscussionLabel + if len(input.Labels) > 0 { + resolvedLabels, err = c.resolveLabels(repo, input.Labels) + if err != nil { + return nil, err + } + } + var mutation struct { CreateDiscussion struct { Discussion struct { @@ -941,11 +951,7 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI }) } - if len(input.Labels) > 0 { - resolvedLabels, err := c.resolveLabels(repo, input.Labels) - if err != nil { - return nil, err - } + if len(resolvedLabels) > 0 { labelIDs := make([]string, len(resolvedLabels)) for i, l := range resolvedLabels { labelIDs[i] = l.ID diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 8ca5f52b2f7..d7df9812bbc 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2934,7 +2934,7 @@ func TestCreate(t *testing.T) { }, }, { - name: "label not found returns error", + name: "label not found returns error without creating discussion", input: CreateDiscussionInput{ CategoryID: "CAT_1", Title: "Test", @@ -2946,38 +2946,8 @@ func TestCreate(t *testing.T) { 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, - "comments": {"totalCount": 0} - } - } - } - } - `)), - ) + // No CreateDiscussion stub — reg.Verify(t) proves it is never called, + // confirming that label validation is atomic with discussion creation. reg.Register( httpmock.GraphQL(`query RepositoryLabels\b`), httpmock.StringResponse(heredoc.Doc(` diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 8504c2c4cb8..715e9d7e28d 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -44,11 +44,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co Long: heredoc.Doc(` Create a new GitHub Discussion in a repository. - With '--title' and '--category', a discussion is created non-interactively. - Omitting either flag triggers interactive prompts when connected to a terminal. - - The '--body' flag provides the discussion body. Without it you will be - prompted to enter one in your default editor. + 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 @@ -86,13 +83,25 @@ func createRun(opts *CreateOptions) error { return err } + interactive := opts.IO.CanPrompt() + + if !interactive { + if opts.Title == "" { + return cmdutil.FlagErrorf("--title required when not running interactively") + } + if opts.Category == "" { + return cmdutil.FlagErrorf("--category required when not running interactively") + } + if opts.Body == "" { + return cmdutil.FlagErrorf("--body required when not running interactively") + } + } + categories, err := c.ListCategories(repo) if err != nil { return fmt.Errorf("fetching categories: %w", err) } - interactive := opts.IO.CanPrompt() - if opts.Title == "" { if !interactive { return cmdutil.FlagErrorf("--title required when not running interactively") @@ -131,7 +140,7 @@ func createRun(opts *CreateOptions) error { if !interactive { return cmdutil.FlagErrorf("--body required when not running interactively") } - opts.Body, err = opts.Prompter.MarkdownEditor("Discussion body", "", true) + opts.Body, err = opts.Prompter.MarkdownEditor("Discussion body", "", false) if err != nil { return err } diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go index 6b5ba12c119..9eede150e59 100644 --- a/pkg/cmd/discussion/create/create_test.go +++ b/pkg/cmd/discussion/create/create_test.go @@ -151,12 +151,8 @@ func TestCreateRun_nonInteractive(t *testing.T) { Body: "Details", Category: "General", }, - setupMock: func(m *client.DiscussionClientMock) { - m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - } - }, - wantErr: "--title required when not running interactively", + setupMock: func(m *client.DiscussionClientMock) {}, + wantErr: "--title required when not running interactively", }, { name: "missing category returns error", @@ -164,12 +160,8 @@ func TestCreateRun_nonInteractive(t *testing.T) { Title: "My question", Body: "Details", }, - setupMock: func(m *client.DiscussionClientMock) { - m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - } - }, - wantErr: "--category required when not running interactively", + setupMock: func(m *client.DiscussionClientMock) {}, + wantErr: "--category required when not running interactively", }, { name: "missing body returns error", @@ -177,12 +169,8 @@ func TestCreateRun_nonInteractive(t *testing.T) { Title: "My question", Category: "General", }, - setupMock: func(m *client.DiscussionClientMock) { - m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - } - }, - wantErr: "--body required when not running interactively", + setupMock: func(m *client.DiscussionClientMock) {}, + wantErr: "--body required when not running interactively", }, { name: "unknown category returns error", @@ -282,6 +270,7 @@ func TestCreateRun_tty(t *testing.T) { 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 }, } @@ -336,3 +325,32 @@ func TestCreateRun_tty_partialFlags(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout.String(), "Created discussion #5") } + +func TestCreateRun_tty_blankTitle(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + } + + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return " ", nil // whitespace only + }, + } + + opts := &CreateOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Prompter: pm, + } + + err := createRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "title cannot be blank") +} From 3850cacb551012f4a9e28b0262270496db8b2bfb Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 08:43:57 +0100 Subject: [PATCH 65/81] fix(discussion create): improve validation and output behavior - Move non-interactive flag validation to arg parsing stage - Add blank title/body/category validation at flag parsing - Keep interactive blank-title/body checks after prompts - Always print URL to stdout; success message to stderr in TTY mode - Consolidate test cases and add isTTY field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/create/create.go | 55 +++++++--------- pkg/cmd/discussion/create/create_test.go | 84 ++++++++++++------------ 2 files changed, 66 insertions(+), 73 deletions(-) diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 715e9d7e28d..09bfc414afc 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -57,6 +57,22 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co 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) } @@ -83,36 +99,19 @@ func createRun(opts *CreateOptions) error { return err } - interactive := opts.IO.CanPrompt() - - if !interactive { - if opts.Title == "" { - return cmdutil.FlagErrorf("--title required when not running interactively") - } - if opts.Category == "" { - return cmdutil.FlagErrorf("--category required when not running interactively") - } - if opts.Body == "" { - return cmdutil.FlagErrorf("--body required when not running interactively") - } - } - categories, err := c.ListCategories(repo) if err != nil { return fmt.Errorf("fetching categories: %w", err) } if opts.Title == "" { - if !interactive { - return cmdutil.FlagErrorf("--title required when not running interactively") - } opts.Title, err = opts.Prompter.Input("Discussion title", "") if err != nil { return err } - } - if strings.TrimSpace(opts.Title) == "" { - return cmdutil.FlagErrorf("title cannot be blank") + if strings.TrimSpace(opts.Title) == "" { + return fmt.Errorf("title cannot be blank") + } } var category *client.DiscussionCategory @@ -122,9 +121,6 @@ func createRun(opts *CreateOptions) error { return err } } else { - if !interactive { - return cmdutil.FlagErrorf("--category required when not running interactively") - } names := make([]string, len(categories)) for i, cat := range categories { names[i] = cat.Name @@ -137,13 +133,13 @@ func createRun(opts *CreateOptions) error { } if opts.Body == "" { - if !interactive { - return cmdutil.FlagErrorf("--body required when not running interactively") - } 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") + } } input := client.CreateDiscussionInput{ @@ -160,11 +156,10 @@ func createRun(opts *CreateOptions) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s Created discussion #%d: %s\n", - cs.SuccessIcon(), discussion.Number, discussion.URL) - } else { - fmt.Fprintln(opts.IO.Out, discussion.URL) + fmt.Fprintf(opts.IO.ErrOut, "%s Created discussion #%d\n", + cs.SuccessIcon(), discussion.Number) } + 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 index 9eede150e59..1a413b32ecc 100644 --- a/pkg/cmd/discussion/create/create_test.go +++ b/pkg/cmd/discussion/create/create_test.go @@ -35,41 +35,65 @@ func TestNewCmdCreate(t *testing.T) { tests := []struct { name string args string + isTTY bool wantOpts CreateOptions wantErr string }{ { name: "no flags", args: "", + isTTY: true, wantOpts: CreateOptions{}, }, { - name: "title flag", - args: "--title 'My question'", - wantOpts: CreateOptions{ - Title: "My question", - }, - }, - { - name: "all flags", - args: "--title 'My question' --body 'Details' --category 'Q&A' --label bug", + 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"}, + 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", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.isTTY) + ios.SetStdoutTTY(tt.isTTY) + f := &cmdutil.Factory{IOStreams: ios} var capturedOpts *CreateOptions cmd := NewCmdCreate(f, func(opts *CreateOptions) error { capturedOpts = opts @@ -145,33 +169,6 @@ func TestCreateRun_nonInteractive(t *testing.T) { }, wantOut: "https://github.com/OWNER/REPO/discussions/5\n", }, - { - name: "missing title returns error", - opts: CreateOptions{ - Body: "Details", - Category: "General", - }, - setupMock: func(m *client.DiscussionClientMock) {}, - wantErr: "--title required when not running interactively", - }, - { - name: "missing category returns error", - opts: CreateOptions{ - Title: "My question", - Body: "Details", - }, - setupMock: func(m *client.DiscussionClientMock) {}, - wantErr: "--category required when not running interactively", - }, - { - name: "missing body returns error", - opts: CreateOptions{ - Title: "My question", - Category: "General", - }, - setupMock: func(m *client.DiscussionClientMock) {}, - wantErr: "--body required when not running interactively", - }, { name: "unknown category returns error", opts: CreateOptions{ @@ -245,7 +242,7 @@ func TestCreateRun_nonInteractive(t *testing.T) { } func TestCreateRun_tty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -284,13 +281,13 @@ func TestCreateRun_tty(t *testing.T) { err := createRun(opts) require.NoError(t, err) - assert.Contains(t, stdout.String(), "Created discussion #5") - assert.Contains(t, stdout.String(), "https://github.com/OWNER/REPO/discussions/5") + assert.Contains(t, stderr.String(), "Created discussion #5") + assert.Equal(t, "https://github.com/OWNER/REPO/discussions/5\n", stdout.String()) } func TestCreateRun_tty_partialFlags(t *testing.T) { // Title and body provided, category via prompt - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -323,7 +320,8 @@ func TestCreateRun_tty_partialFlags(t *testing.T) { err := createRun(opts) require.NoError(t, err) - assert.Contains(t, stdout.String(), "Created discussion #5") + assert.Contains(t, stderr.String(), "Created discussion #5") + assert.Equal(t, "https://github.com/OWNER/REPO/discussions/5\n", stdout.String()) } func TestCreateRun_tty_blankTitle(t *testing.T) { From bdceb21248f85601a6f75a00b269d8aa3561dc03 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 08:58:53 +0100 Subject: [PATCH 66/81] fix(discussion create): allow `--repo` override Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/create/create.go | 2 ++ pkg/cmd/discussion/create/create_test.go | 40 +++++++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 09bfc414afc..0e4d1bd8397 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -80,6 +80,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co }, } + 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") diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go index 1a413b32ecc..c840d8bf553 100644 --- a/pkg/cmd/discussion/create/create_test.go +++ b/pkg/cmd/discussion/create/create_test.go @@ -33,11 +33,12 @@ func sampleDiscussion() *client.Discussion { func TestNewCmdCreate(t *testing.T) { tests := []struct { - name string - args string - isTTY bool - wantOpts CreateOptions - wantErr string + name string + args string + isTTY bool + wantOpts CreateOptions + wantBaseRepo ghrepo.Interface + wantErr string }{ { name: "no flags", @@ -86,6 +87,17 @@ func TestNewCmdCreate(t *testing.T) { 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 { @@ -94,9 +106,9 @@ func TestNewCmdCreate(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStdoutTTY(tt.isTTY) f := &cmdutil.Factory{IOStreams: ios} - var capturedOpts *CreateOptions + var gotOpts *CreateOptions cmd := NewCmdCreate(f, func(opts *CreateOptions) error { - capturedOpts = opts + gotOpts = opts return nil }) cmd.SetIn(&bytes.Buffer{}) @@ -114,10 +126,16 @@ func TestNewCmdCreate(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tt.wantOpts.Title, capturedOpts.Title) - assert.Equal(t, tt.wantOpts.Body, capturedOpts.Body) - assert.Equal(t, tt.wantOpts.Category, capturedOpts.Category) - assert.Equal(t, tt.wantOpts.Labels, capturedOpts.Labels) + 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)) + } }) } } From 47cabb26ae0b3ba1fc05daffe3b01f51bf3ad348 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 09:08:51 +0100 Subject: [PATCH 67/81] fix(discussion create): remove success message from stderr Only print the discussion URL to stdout. No additional output on stderr. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/create/create.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 0e4d1bd8397..6a0c1e2ad57 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -153,14 +153,9 @@ func createRun(opts *CreateOptions) error { discussion, err := c.Create(repo, input) if err != nil { - return fmt.Errorf("creating discussion: %w", err) + return fmt.Errorf("failed to create discussion: %w", err) } - if opts.IO.IsStdoutTTY() { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Created discussion #%d\n", - cs.SuccessIcon(), discussion.Number) - } fmt.Fprintln(opts.IO.Out, discussion.URL) return nil From 0c0d316b9a5536a7833653b1d6de2ae3140ff016 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 09:08:51 +0100 Subject: [PATCH 68/81] test(discussion create): consolidate into TestCreateRun table test - Merge TestCreateRun_nonInteractive, TestCreateRun_tty, and related tests into a single TestCreateRun table with 11 cases - Add partial-flag cases for missing title, body, and category - Add tty blank body returns error case - Add tty does not prompt when all flags provided case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/create/create_test.go | 327 +++++++++++++---------- 1 file changed, 192 insertions(+), 135 deletions(-) diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go index c840d8bf553..8aa273aaef4 100644 --- a/pkg/cmd/discussion/create/create_test.go +++ b/pkg/cmd/discussion/create/create_test.go @@ -15,22 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -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", - } -} - func TestNewCmdCreate(t *testing.T) { tests := []struct { name string @@ -140,16 +124,18 @@ func TestNewCmdCreate(t *testing.T) { } } -func TestCreateRun_nonInteractive(t *testing.T) { +func TestCreateRun(t *testing.T) { tests := []struct { name string opts CreateOptions + isTTY bool + setupMock func(*client.DiscussionClientMock) + prompter *prompter.PrompterMock wantErr string wantOut string - setupMock func(*client.DiscussionClientMock) }{ { - name: "creates discussion successfully", + name: "success non-tty", opts: CreateOptions{ Title: "My question", Body: "Details", @@ -169,26 +155,26 @@ func TestCreateRun_nonInteractive(t *testing.T) { wantOut: "https://github.com/OWNER/REPO/discussions/5\n", }, { - name: "creates with label", + name: "success non-tty with label", opts: CreateOptions{ Title: "Feature request", Body: "Details", Category: "general", - Labels: []string{"enhancement"}, + Labels: []string{"enhancement", "bug"}, }, 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, []string{"enhancement"}, input.Labels) + assert.Equal(t, []string{"enhancement", "bug"}, input.Labels) return sampleDiscussion(), nil } }, wantOut: "https://github.com/OWNER/REPO/discussions/5\n", }, { - name: "unknown category returns error", + name: "non-tty unknown category", opts: CreateOptions{ Title: "My question", Body: "Details", @@ -202,7 +188,7 @@ func TestCreateRun_nonInteractive(t *testing.T) { wantErr: `unknown category: "nonexistent"`, }, { - name: "ListCategories error propagates", + name: "non-tty list categories query errors", opts: CreateOptions{ Title: "My question", Body: "Details", @@ -216,7 +202,7 @@ func TestCreateRun_nonInteractive(t *testing.T) { wantErr: "fetching categories: network error", }, { - name: "Create error propagates", + name: "non-tty create mutation errors", opts: CreateOptions{ Title: "My question", Body: "Details", @@ -230,22 +216,189 @@ func TestCreateRun_nonInteractive(t *testing.T) { return nil, fmt.Errorf("mutation failed") } }, - wantErr: "creating discussion: 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() - // non-interactive: no TTY + 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 } + 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 != "" { @@ -259,114 +412,18 @@ func TestCreateRun_nonInteractive(t *testing.T) { } } -func TestCreateRun_tty(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - - mockClient := &client.DiscussionClientMock{ - ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - }, - 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 - }, - } - - pm := &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 - }, - } - - opts := &CreateOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - Client: func() (client.DiscussionClient, error) { return mockClient, nil }, - Prompter: pm, - } - - err := createRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "Created discussion #5") - assert.Equal(t, "https://github.com/OWNER/REPO/discussions/5\n", stdout.String()) -} - -func TestCreateRun_tty_partialFlags(t *testing.T) { - // Title and body provided, category via prompt - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - - mockClient := &client.DiscussionClientMock{ - ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - }, - 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 - }, - } - - pm := &prompter.PrompterMock{ - SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { - return 1, nil // select Q&A - }, - } - - opts := &CreateOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - Client: func() (client.DiscussionClient, error) { return mockClient, nil }, - Prompter: pm, - Title: "Pre-filled title", - Body: "Pre-filled body", +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"}, } - - err := createRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "Created discussion #5") - assert.Equal(t, "https://github.com/OWNER/REPO/discussions/5\n", stdout.String()) } -func TestCreateRun_tty_blankTitle(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - - mockClient := &client.DiscussionClientMock{ - ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { - return sampleCategories(), nil - }, - } - - pm := &prompter.PrompterMock{ - InputFunc: func(prompt, defaultValue string) (string, error) { - return " ", nil // whitespace only - }, - } - - opts := &CreateOptions{ - IO: ios, - BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - Client: func() (client.DiscussionClient, error) { return mockClient, nil }, - Prompter: pm, +func sampleDiscussion() *client.Discussion { + return &client.Discussion{ + Number: 5, + Title: "My question", + URL: "https://github.com/OWNER/REPO/discussions/5", } - - err := createRun(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "title cannot be blank") } From 25778ce08a9b9d65e7ec540c5afcc1bc9eb8f0ec Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 09:36:21 +0100 Subject: [PATCH 69/81] docs(discussion create): add preview suffix Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 6a0c1e2ad57..03cf32cc50b 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -40,7 +40,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "create", - Short: "Create a new discussion", + Short: "Create a new discussion (preview)", Long: heredoc.Doc(` Create a new GitHub Discussion in a repository. From c87d262d4b66da85d1e0bc290a74720b9d9d24a8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 7 May 2026 09:58:42 +0100 Subject: [PATCH 70/81] fix(discussion create): display spinner Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/create/create.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 03cf32cc50b..5ff4d0a03bc 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -101,7 +101,9 @@ func createRun(opts *CreateOptions) error { return err } + opts.IO.StartProgressIndicator() categories, err := c.ListCategories(repo) + opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("fetching categories: %w", err) } @@ -151,7 +153,9 @@ func createRun(opts *CreateOptions) error { Labels: opts.Labels, } + opts.IO.StartProgressIndicator() discussion, err := c.Create(repo, input) + opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to create discussion: %w", err) } From 3366cf9064c620536fcb512a40ea39d46b7ebd9b Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 8 May 2026 16:09:10 -0500 Subject: [PATCH 71/81] Add gh discussion edit command Implements `gh discussion edit ` with support for --title, --body, --body-file, and --category flags. Supports both non-interactive (flags) and interactive (TTY) modes. Category can be specified by name or slug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 148 +++++- pkg/cmd/discussion/client/client_impl_test.go | 189 ++++++++ pkg/cmd/discussion/discussion.go | 2 + pkg/cmd/discussion/edit/edit.go | 233 +++++++++ pkg/cmd/discussion/edit/edit_test.go | 459 ++++++++++++++++++ 5 files changed, 1029 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/discussion/edit/edit.go create mode 100644 pkg/cmd/discussion/edit/edit_test.go diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 5497eb95588..d0b4565c1eb 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -53,6 +53,72 @@ func mapActorFromListNode(n actorNode) DiscussionActor { return a } +// rawActorNode is a JSON-compatible actor type for use with c.gql.GraphQL() (raw +// query strings). Unlike actorNode, it does not use shurcooL graphql struct tags +// and works with standard json.Unmarshal. GitHub flattens inline fragment fields +// (... on User { id name }) to the top level of the actor object in JSON responses. +type rawActorNode struct { + TypeName string `json:"__typename"` + Login string + ID string + Name string +} + +func mapActorFromRawNode(n rawActorNode) DiscussionActor { + a := DiscussionActor{Login: n.Login} + switch n.TypeName { + case "User": + a.ID = n.ID + a.Name = n.Name + case "Bot": + a.ID = n.ID + } + return a +} + +// updateDiscussionDiscNode is the JSON response shape for the discussion field +// inside an updateDiscussion mutation response. +type updateDiscussionDiscNode struct { + ID string + Number int + Title string + Body string + URL string + Closed bool + StateReason string + Author rawActorNode + Category struct { + ID string + Name string + Slug string + Emoji string + IsAnswerable bool + } + Labels struct { + Nodes []struct { + ID string + Name string + Color string + } + } + IsAnswered bool + AnswerChosenAt time.Time + AnswerChosenBy *rawActorNode + ReactionGroups []struct { + Content string + Users struct { + TotalCount int + } + } + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt time.Time + Locked bool + Comments struct { + TotalCount int + } +} + // 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). @@ -965,8 +1031,86 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI return &d, nil } -func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +const updateDiscussionMutation = ` +mutation UpdateDiscussion($input: UpdateDiscussionInput!) { + updateDiscussion(input: $input) { + discussion { + id number title body url closed stateReason + isAnswered answerChosenAt + author { __typename login ... on User { id name } ... on Bot { id } } + answerChosenBy { __typename login ... on User { id name } ... on Bot { id } } + category { id name slug emoji isAnswerable } + labels(first: 20) { nodes { id name color } } + reactionGroups { content users { totalCount } } + createdAt updatedAt closedAt locked + comments { totalCount } + } + } +}` + +func (c *discussionClient) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { + inputMap := map[string]interface{}{ + "discussionId": input.DiscussionID, + } + if input.Title != nil { + inputMap["title"] = *input.Title + } + if input.Body != nil { + inputMap["body"] = *input.Body + } + if input.CategoryID != nil { + inputMap["categoryId"] = *input.CategoryID + } + + variables := map[string]interface{}{ + "input": inputMap, + } + + var result struct { + UpdateDiscussion struct { + Discussion updateDiscussionDiscNode + } + } + if err := c.gql.GraphQL(repo.RepoHost(), updateDiscussionMutation, variables, &result); err != nil { + return nil, err + } + + disc := result.UpdateDiscussion.Discussion + d := Discussion{ + ID: disc.ID, + Number: disc.Number, + Title: disc.Title, + Body: disc.Body, + URL: disc.URL, + Closed: disc.Closed, + StateReason: disc.StateReason, + Author: mapActorFromRawNode(disc.Author), + Category: DiscussionCategory{ID: disc.Category.ID, Name: disc.Category.Name, Slug: disc.Category.Slug, Emoji: disc.Category.Emoji, IsAnswerable: disc.Category.IsAnswerable}, + Answered: disc.IsAnswered, + AnswerChosenAt: disc.AnswerChosenAt, + CreatedAt: disc.CreatedAt, + UpdatedAt: disc.UpdatedAt, + ClosedAt: disc.ClosedAt, + Locked: disc.Locked, + Comments: DiscussionCommentList{TotalCount: disc.Comments.TotalCount}, + } + + if disc.AnswerChosenBy != nil { + a := mapActorFromRawNode(*disc.AnswerChosenBy) + d.AnswerChosenBy = &a + } + + d.Labels = make([]DiscussionLabel, len(disc.Labels.Nodes)) + for i, l := range disc.Labels.Nodes { + d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} + } + + d.ReactionGroups = make([]ReactionGroup, 0, len(disc.ReactionGroups)) + for _, rg := range disc.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) { diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index d7df9812bbc..e824bb5bbb7 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -3067,3 +3067,192 @@ func TestCreate(t *testing.T) { }) } } + +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: "maps all fields", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + Title: &titleStr, + Body: &bodyStr, + CategoryID: &catID, + }, + 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": []}, + "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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + }, + 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{}, + 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, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + }, + 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{}, + ReactionGroups: []ReactionGroup{}, + 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'.", + }, + } + + 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/discussion.go b/pkg/cmd/discussion/discussion.go index 7e8d1f2527a..2ebc60a31cb 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -3,6 +3,7 @@ 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" @@ -40,6 +41,7 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { ) cmdutil.AddGroup(cmd, "Targeted commands", + cmdEdit.NewCmdEdit(f, nil), cmdView.NewCmdView(f, nil), ) diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go new file mode 100644 index 00000000000..4b80f02c1cf --- /dev/null +++ b/pkg/cmd/discussion/edit/edit.go @@ -0,0 +1,233 @@ +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 + + DiscussionNumber int + Title string + Body string + BodyFile string + Category 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.Docf(` + Edit a GitHub Discussion. + + With %[1]s--title%[1]s, %[1]s--body%[1]s, and %[1]s--category%[1]s flags, the discussion is updated + non-interactively. Omitting all flags triggers interactive prompts when connected to a terminal. + `, "`"), + Example: heredoc.Doc(` + # Edit interactively + $ gh discussion edit 123 + + # Set a new title + $ gh discussion edit 123 --title "Updated title" + + # Change the category + $ gh discussion edit 123 --category "Ideas" + + # Update body from a file + $ gh discussion edit 123 --body-file body.md + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.MutuallyExclusive("specify only one of --body or --body-file", + opts.Body != "", opts.BodyFile != ""); err != nil { + return err + } + + number, repo, err := shared.ParseDiscussionArg(args[0]) + if err != nil { + return cmdutil.FlagErrorf("%s", err) + } + + if repo != nil { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return repo, nil + } + } else { + opts.BaseRepo = f.BaseRepo + } + + opts.DiscussionNumber = number + + noFlagsSet := opts.Title == "" && opts.Body == "" && opts.BodyFile == "" && opts.Category == "" + if noFlagsSet && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("specify at least one of --title, --body, --body-file, or --category when not running interactively") + } + + 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") + + 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 fmt.Errorf("fetching discussion: %w", err) + } + + // Resolve body from file if provided. + if opts.BodyFile != "" { + bodyBytes, err := cmdutil.ReadFile(opts.BodyFile, opts.IO.In) + if err != nil { + return err + } + opts.Body = string(bodyBytes) + } + + input := client.UpdateDiscussionInput{ + DiscussionID: discussion.ID, + } + + noFlagsSet := opts.Title == "" && opts.Body == "" && opts.Category == "" + if noFlagsSet { + // Interactive mode: prompt user to select which fields to edit. + if err := promptEdit(opts, discussion, c, repo, &input); err != nil { + return err + } + } else { + // Non-interactive: apply only the flags that were set. + if opts.Title != "" { + if strings.TrimSpace(opts.Title) == "" { + return cmdutil.FlagErrorf("title cannot be blank") + } + input.Title = &opts.Title + } + if opts.Body != "" { + input.Body = &opts.Body + } + if opts.Category != "" { + 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 + } + } + + opts.IO.StartProgressIndicator() + updated, err := c.Update(repo, input) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to update discussion: %w", err) + } + + fmt.Fprintln(opts.IO.Out, updated.URL) + return nil +} + +// promptEdit runs the interactive flow, populating input with user choices. +func promptEdit(opts *EditOptions, discussion *client.Discussion, c client.DiscussionClient, repo ghrepo.Interface, input *client.UpdateDiscussionInput) error { + choices := []string{"title", "body", "category"} + selected, err := opts.Prompter.MultiSelect("What would you like to edit?", nil, choices) + if err != nil { + return err + } + if len(selected) == 0 { + return nil + } + + for _, idx := range selected { + switch choices[idx] { + case "title": + title, err := opts.Prompter.Input("Discussion title", discussion.Title) + if err != nil { + return err + } + if strings.TrimSpace(title) == "" { + return fmt.Errorf("title cannot be blank") + } + input.Title = &title + + case "body": + body, err := opts.Prompter.MarkdownEditor("Discussion body", discussion.Body, false) + if err != nil { + return err + } + input.Body = &body + + case "category": + opts.IO.StartProgressIndicator() + categories, err := c.ListCategories(repo) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("fetching categories: %w", err) + } + names := make([]string, len(categories)) + for i, cat := range categories { + names[i] = cat.Name + } + currentName := discussion.Category.Name + idx, err := opts.Prompter.Select("Discussion category", currentName, names) + if err != nil { + return err + } + input.CategoryID = &categories[idx].ID + } + } + + return 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..e843994fad8 --- /dev/null +++ b/pkg/cmd/discussion/edit/edit_test.go @@ -0,0 +1,459 @@ +package edit + +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 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, + Title: "New title", + Body: "New body", + Category: "Ideas", + }, + }, + { + name: "url arg overrides base repo", + args: "https://github.com/OWNER2/REPO2/discussions/42", + isTTY: true, + wantOpts: EditOptions{ + DiscussionNumber: 42, + }, + wantBaseRepo: ghrepo.New("OWNER2", "REPO2"), + }, + { + 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 of --title, --body, --body-file, or --category when not running 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.Title, gotOpts.Title) + assert.Equal(t, tt.wantOpts.Body, gotOpts.Body) + assert.Equal(t, tt.wantOpts.Category, gotOpts.Category) + + 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 + isTTY bool + setupMock func(*client.DiscussionClientMock) + prompter *prompter.PrompterMock + wantErr string + wantOut string + }{ + { + name: "success non-tty title only", + opts: EditOptions{ + Title: "Updated title", + }, + 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", + }, + 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", + }, + 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 all flags", + opts: EditOptions{ + Title: "New title", + Body: "New body", + Category: "General", + }, + 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) { + 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) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "non-tty blank title returns error", + opts: EditOptions{ + Title: " ", + }, + 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", + }, + 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", + }, + 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: "fetching categories: network error", + }, + { + name: "GetByNumber error", + opts: EditOptions{ + Title: "whatever", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return nil, fmt.Errorf("not found") + } + }, + wantErr: "fetching discussion: not found", + }, + { + name: "Update error", + opts: EditOptions{ + Title: "Updated title", + }, + 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: "failed to update discussion: mutation failed", + }, + { + name: "tty interactive select title", + isTTY: 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, + 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, + 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 still calls Update", + isTTY: 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) + assert.Nil(t, 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) { + return []int{}, nil + }, + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "tty interactive blank title returns error", + isTTY: 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", + }, + } + + 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{} + if tt.setupMock != nil { + 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 := 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", + }, + } +} From f3766aa8fe402d24d8065cf62cc650f4382f8b6c Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 9 May 2026 13:23:46 -0500 Subject: [PATCH 72/81] fix: skip Update API call when interactive user selects nothing to edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user runs 'gh discussion edit' interactively and dismisses the MultiSelect prompt without choosing any fields, the previous code still called Update() with only DiscussionID set — a pointless no-op API call. Add an early-return guard after promptEdit() returns so that we skip the Update call when no fields were modified. Also add a --body-file test case to cover the file-reading path, and update the existing 'nothing selected' test to assert that Update is not called in the no-op scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/edit/edit.go | 7 ++++ pkg/cmd/discussion/edit/edit_test.go | 50 +++++++++++++++++++--------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go index 4b80f02c1cf..129ce89fc7e 100644 --- a/pkg/cmd/discussion/edit/edit.go +++ b/pkg/cmd/discussion/edit/edit.go @@ -136,12 +136,19 @@ func editRun(opts *EditOptions) error { DiscussionID: discussion.ID, } + // noFlagsSet omits BodyFile intentionally: ReadFile above already copied its + // contents into opts.Body, so Body == "" implies no body update was requested. noFlagsSet := opts.Title == "" && opts.Body == "" && opts.Category == "" if noFlagsSet { // Interactive mode: prompt user to select which fields to edit. if err := promptEdit(opts, discussion, c, repo, &input); err != nil { return err } + // If the user dismissed the prompt without selecting anything, skip the + // API call — there is nothing to update. + if input.Title == nil && input.Body == nil && input.CategoryID == nil { + return nil + } } else { // Non-interactive: apply only the flags that were set. if opts.Title != "" { diff --git a/pkg/cmd/discussion/edit/edit_test.go b/pkg/cmd/discussion/edit/edit_test.go index e843994fad8..d4289f8554d 100644 --- a/pkg/cmd/discussion/edit/edit_test.go +++ b/pkg/cmd/discussion/edit/edit_test.go @@ -3,6 +3,8 @@ package edit import ( "bytes" "fmt" + "os" + "path/filepath" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -112,13 +114,14 @@ func TestNewCmdEdit(t *testing.T) { func TestEditRun(t *testing.T) { tests := []struct { - name string - opts EditOptions - isTTY bool - setupMock func(*client.DiscussionClientMock) - prompter *prompter.PrompterMock - wantErr string - wantOut string + name string + opts EditOptions + bodyFileContent string // if non-empty, creates a temp file and sets opts.BodyFile + isTTY bool + setupMock func(*client.DiscussionClientMock) + prompter *prompter.PrompterMock + wantErr string + wantOut string }{ { name: "success non-tty title only", @@ -359,25 +362,36 @@ func TestEditRun(t *testing.T) { wantOut: "https://github.com/OWNER/REPO/discussions/5\n", }, { - name: "tty interactive nothing selected still calls Update", + name: "tty interactive nothing selected is a no-op", isTTY: 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) - assert.Nil(t, input.Title) - assert.Nil(t, input.Body) - assert.Nil(t, input.CategoryID) - return sampleDiscussion(), nil - } + // UpdateFunc intentionally not set: Update must not be called when nothing is selected. }, prompter: &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { return []int{}, nil }, }, + wantOut: "", + }, + { + name: "success non-tty body-file", + bodyFileContent: "Body from file", + 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", }, { @@ -412,6 +426,12 @@ func TestEditRun(t *testing.T) { } 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 From 4369ddbdad54da7c74dc401a02a4b447566490e7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 11:30:22 +0100 Subject: [PATCH 73/81] refactor(discussion): wrap error Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/edit/edit.go | 2 +- pkg/cmd/discussion/view/view.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go index 129ce89fc7e..20400a7326a 100644 --- a/pkg/cmd/discussion/edit/edit.go +++ b/pkg/cmd/discussion/edit/edit.go @@ -70,7 +70,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { - return cmdutil.FlagErrorf("%s", err) + return cmdutil.FlagErrorWrap(err) } if repo != nil { diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index 98b1d608d71..8b8d1e24b30 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -164,7 +164,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { - return cmdutil.FlagErrorf("%s", err) + return cmdutil.FlagErrorWrap(err) } if repo != nil { From 0dd88dd6777295c9dde4303ea92b35b5fc92fe2a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 12:14:38 +0100 Subject: [PATCH 74/81] refactor(discussion/client): rename addLabelsToDiscussion to editDiscussionLabels Support both adding and removing labels in a single method call. Removals are applied before additions. Either slice may be nil to skip that step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 65 ++++++++--- pkg/cmd/discussion/client/client_impl_test.go | 109 ++++++++++++++++++ 2 files changed, 157 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index d0b4565c1eb..f1661a2cb5f 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -941,27 +941,58 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str return result, nil } -// addLabelsToDiscussion applies labels to a discussion via the addLabelsToLabelable mutation. -func (c *discussionClient) addLabelsToDiscussion(repo ghrepo.Interface, discussionID string, labelIDs []string) error { - ids := make([]githubv4.ID, len(labelIDs)) - for i, id := range labelIDs { - ids[i] = githubv4.ID(id) - } +// editDiscussionLabels adds and removes labels on a discussion. Removals are +// applied before additions. Either slice may be nil or empty to skip that step. +func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussionID string, addIDs, removeIDs []string) error { + if len(removeIDs) > 0 { + ids := make([]githubv4.ID, len(removeIDs)) + for i, id := range removeIDs { + ids[i] = githubv4.ID(id) + } - var mutation struct { - AddLabelsToLabelable struct { - Typename string `graphql:"__typename"` - } `graphql:"addLabelsToLabelable(input: $input)"` + var mutation struct { + RemoveLabelsFromLabelable struct { + Typename string `graphql:"__typename"` + } `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 err + } } - variables := map[string]interface{}{ - "input": githubv4.AddLabelsToLabelableInput{ - LabelableID: githubv4.ID(discussionID), - LabelIDs: ids, - }, + 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 { + Typename string `graphql:"__typename"` + } `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 err + } } - return c.gql.Mutate(repo.RepoHost(), "AddLabelsToDiscussion", &mutation, variables) + return nil } func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { @@ -1022,7 +1053,7 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI for i, l := range resolvedLabels { labelIDs[i] = l.ID } - if err := c.addLabelsToDiscussion(repo, d.ID, labelIDs); err != nil { + if err := c.editDiscussionLabels(repo, d.ID, labelIDs, nil); err != nil { return nil, err } d.Labels = resolvedLabels diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index e824bb5bbb7..aefd19630b2 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -3068,6 +3068,115 @@ func TestCreate(t *testing.T) { } } +func TestEditDiscussionLabels(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + addIDs []string + removeIDs []string + setupMock func(reg *httpmock.Registry) + wantErr string + }{ + { + 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 + }), + httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"__typename":"Labelable"}}}`), + ) + 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(`{"data":{"addLabelsToLabelable":{"__typename":"Labelable"}}}`), + ) + }, + }, + { + name: "only adds labels", + addIDs: []string{"L_bug"}, + removeIDs: nil, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Labelable"}}}`), + ) + }, + }, + { + name: "only removes labels", + addIDs: nil, + removeIDs: []string{"L_old"}, + setupMock: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), + httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"__typename":"Labelable"}}}`), + ) + }, + }, + { + 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) + + 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) + }) + } +} + func TestUpdate(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") From 905e8dd7c222536dfbb096264bc3f9f02d5e6d49 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 12:41:31 +0100 Subject: [PATCH 75/81] fix(discussion/client): remove unused comments field Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 4 ---- pkg/cmd/discussion/client/client_impl_test.go | 15 +++++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index f1661a2cb5f..49c46656058 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -1018,9 +1018,6 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI CreateDiscussion struct { Discussion struct { discussionListNode - Comments struct { - TotalCount int - } } } `graphql:"createDiscussion(input: $input)"` } @@ -1039,7 +1036,6 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI } d := mapDiscussionFromListNode(mutation.CreateDiscussion.Discussion.discussionListNode) - d.Comments = DiscussionCommentList{TotalCount: mutation.CreateDiscussion.Discussion.Comments.TotalCount} for _, rg := range mutation.CreateDiscussion.Discussion.ReactionGroups { d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index aefd19630b2..76df736f9ae 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2539,8 +2539,7 @@ func TestCreate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -2678,8 +2677,7 @@ func TestCreate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -2783,8 +2781,7 @@ func TestCreate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -2874,8 +2871,7 @@ func TestCreate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -3003,8 +2999,7 @@ func TestCreate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } From 4f640e5dbecc665f7f387a796a11efe0cd7a14fd Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 12:42:11 +0100 Subject: [PATCH 76/81] fix(discussion/client): use strongly-typed query for updating discussions Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 153 +++--------------- pkg/cmd/discussion/client/client_impl_test.go | 20 ++- 2 files changed, 38 insertions(+), 135 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 49c46656058..7df249fe260 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -53,72 +53,6 @@ func mapActorFromListNode(n actorNode) DiscussionActor { return a } -// rawActorNode is a JSON-compatible actor type for use with c.gql.GraphQL() (raw -// query strings). Unlike actorNode, it does not use shurcooL graphql struct tags -// and works with standard json.Unmarshal. GitHub flattens inline fragment fields -// (... on User { id name }) to the top level of the actor object in JSON responses. -type rawActorNode struct { - TypeName string `json:"__typename"` - Login string - ID string - Name string -} - -func mapActorFromRawNode(n rawActorNode) DiscussionActor { - a := DiscussionActor{Login: n.Login} - switch n.TypeName { - case "User": - a.ID = n.ID - a.Name = n.Name - case "Bot": - a.ID = n.ID - } - return a -} - -// updateDiscussionDiscNode is the JSON response shape for the discussion field -// inside an updateDiscussion mutation response. -type updateDiscussionDiscNode struct { - ID string - Number int - Title string - Body string - URL string - Closed bool - StateReason string - Author rawActorNode - Category struct { - ID string - Name string - Slug string - Emoji string - IsAnswerable bool - } - Labels struct { - Nodes []struct { - ID string - Name string - Color string - } - } - IsAnswered bool - AnswerChosenAt time.Time - AnswerChosenBy *rawActorNode - ReactionGroups []struct { - Content string - Users struct { - TotalCount int - } - } - CreatedAt time.Time - UpdatedAt time.Time - ClosedAt time.Time - Locked bool - Comments struct { - TotalCount int - } -} - // 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). @@ -1058,83 +992,48 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI return &d, nil } -const updateDiscussionMutation = ` -mutation UpdateDiscussion($input: UpdateDiscussionInput!) { - updateDiscussion(input: $input) { - discussion { - id number title body url closed stateReason - isAnswered answerChosenAt - author { __typename login ... on User { id name } ... on Bot { id } } - answerChosenBy { __typename login ... on User { id name } ... on Bot { id } } - category { id name slug emoji isAnswerable } - labels(first: 20) { nodes { id name color } } - reactionGroups { content users { totalCount } } - createdAt updatedAt closedAt locked - comments { totalCount } - } - } -}` - func (c *discussionClient) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { - inputMap := map[string]interface{}{ - "discussionId": input.DiscussionID, + if input.Title == nil && input.Body == nil && input.CategoryID == nil { + return nil, fmt.Errorf("nothing to update") + } + + gqlInput := githubv4.UpdateDiscussionInput{ + DiscussionID: githubv4.ID(input.DiscussionID), } if input.Title != nil { - inputMap["title"] = *input.Title + gqlInput.Title = githubv4.NewString(githubv4.String(*input.Title)) } if input.Body != nil { - inputMap["body"] = *input.Body + gqlInput.Body = githubv4.NewString(githubv4.String(*input.Body)) } if input.CategoryID != nil { - inputMap["categoryId"] = *input.CategoryID - } - - variables := map[string]interface{}{ - "input": inputMap, + id := githubv4.ID(*input.CategoryID) + gqlInput.CategoryID = &id } - var result struct { + var mutation struct { UpdateDiscussion struct { - Discussion updateDiscussionDiscNode - } - } - if err := c.gql.GraphQL(repo.RepoHost(), updateDiscussionMutation, variables, &result); err != nil { - return nil, err + Discussion struct { + discussionListNode + } + } `graphql:"updateDiscussion(input: $input)"` } - disc := result.UpdateDiscussion.Discussion - d := Discussion{ - ID: disc.ID, - Number: disc.Number, - Title: disc.Title, - Body: disc.Body, - URL: disc.URL, - Closed: disc.Closed, - StateReason: disc.StateReason, - Author: mapActorFromRawNode(disc.Author), - Category: DiscussionCategory{ID: disc.Category.ID, Name: disc.Category.Name, Slug: disc.Category.Slug, Emoji: disc.Category.Emoji, IsAnswerable: disc.Category.IsAnswerable}, - Answered: disc.IsAnswered, - AnswerChosenAt: disc.AnswerChosenAt, - CreatedAt: disc.CreatedAt, - UpdatedAt: disc.UpdatedAt, - ClosedAt: disc.ClosedAt, - Locked: disc.Locked, - Comments: DiscussionCommentList{TotalCount: disc.Comments.TotalCount}, - } - - if disc.AnswerChosenBy != nil { - a := mapActorFromRawNode(*disc.AnswerChosenBy) - d.AnswerChosenBy = &a + variables := map[string]interface{}{ + "input": gqlInput, } - d.Labels = make([]DiscussionLabel, len(disc.Labels.Nodes)) - for i, l := range disc.Labels.Nodes { - d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} + if err := c.gql.Mutate(repo.RepoHost(), "UpdateDiscussion", &mutation, variables); err != nil { + return nil, err } - d.ReactionGroups = make([]ReactionGroup, 0, len(disc.ReactionGroups)) - for _, rg := range disc.ReactionGroups { - d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount}) + d := mapDiscussionFromListNode(mutation.UpdateDiscussion.Discussion.discussionListNode) + + for _, rg := range mutation.UpdateDiscussion.Discussion.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) } return &d, nil diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 76df736f9ae..276c81b95d5 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -3186,6 +3186,13 @@ func TestUpdate(t *testing.T) { wantErr string assertDisc *Discussion }{ + { + name: "nothing to update", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + }, + wantErr: "nothing to update", + }, { name: "maps all fields", input: UpdateDiscussionInput{ @@ -3219,8 +3226,7 @@ func TestUpdate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-02T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -3279,8 +3285,7 @@ func TestUpdate(t *testing.T) { "createdAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-02T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", - "locked": false, - "comments": {"totalCount": 0} + "locked": false } } } @@ -3301,10 +3306,9 @@ func TestUpdate(t *testing.T) { Slug: "general", Emoji: ":speech_balloon:", }, - Labels: []DiscussionLabel{}, - ReactionGroups: []ReactionGroup{}, - CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2025, 6, 2, 0, 0, 0, 0, time.UTC), + 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), }, }, { From 4feb982bf8ea56b8355dcfdb6783c8d9b9b5685a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 13:44:45 +0100 Subject: [PATCH 77/81] feat(discussion/client): add label mutation support to Update Add AddLabelIDs and RemoveLabelIDs fields to UpdateDiscussionInput so callers can modify discussion labels through the Update method. Refactor editDiscussionLabels to return *discussionListNode so both Create and Update can use the label mutation response as the final discussion state. Both methods now follow the same pattern: a pointer tracks the latest node, label mutations replace it if executed, and the final node is mapped to the domain Discussion type. Replace the raw updateDiscussion GraphQL query with a strongly typed shurcooL Mutate call, reusing discussionListNode and mapDiscussionFromListNode (same as Create). Remove unused types: rawActorNode, mapActorFromRawNode, updateDiscussionDiscNode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 138 ++++--- pkg/cmd/discussion/client/client_impl_test.go | 361 +++++++++++++++++- pkg/cmd/discussion/client/types.go | 10 +- 3 files changed, 452 insertions(+), 57 deletions(-) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 7df249fe260..02e69dfa3d0 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -877,7 +877,10 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str // editDiscussionLabels adds and removes labels on a discussion. Removals are // applied before additions. Either slice may be nil or empty to skip that step. -func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussionID string, addIDs, removeIDs []string) error { +// 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 { @@ -886,7 +889,11 @@ func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussio var mutation struct { RemoveLabelsFromLabelable struct { - Typename string `graphql:"__typename"` + Labelable struct { + Discussion struct { + discussionListNode + } `graphql:"... on Discussion"` + } } `graphql:"removeLabelsFromLabelable(input: $input)"` } @@ -898,8 +905,9 @@ func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussio } if err := c.gql.Mutate(repo.RepoHost(), "RemoveLabelsFromDiscussion", &mutation, variables); err != nil { - return err + return nil, err } + node = &mutation.RemoveLabelsFromLabelable.Labelable.Discussion.discussionListNode } if len(addIDs) > 0 { @@ -910,7 +918,11 @@ func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussio var mutation struct { AddLabelsToLabelable struct { - Typename string `graphql:"__typename"` + Labelable struct { + Discussion struct { + discussionListNode + } `graphql:"... on Discussion"` + } } `graphql:"addLabelsToLabelable(input: $input)"` } @@ -922,11 +934,12 @@ func (c *discussionClient) editDiscussionLabels(repo ghrepo.Interface, discussio } if err := c.gql.Mutate(repo.RepoHost(), "AddLabelsToDiscussion", &mutation, variables); err != nil { - return err + return nil, err } + node = &mutation.AddLabelsToLabelable.Labelable.Discussion.discussionListNode } - return nil + return node, nil } func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { @@ -969,67 +982,106 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI return nil, err } - d := mapDiscussionFromListNode(mutation.CreateDiscussion.Discussion.discussionListNode) - - for _, rg := range mutation.CreateDiscussion.Discussion.ReactionGroups { - d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ - Content: rg.Content, - TotalCount: rg.Users.TotalCount, - }) - } + node := &mutation.CreateDiscussion.Discussion.discussionListNode if len(resolvedLabels) > 0 { labelIDs := make([]string, len(resolvedLabels)) for i, l := range resolvedLabels { labelIDs[i] = l.ID } - if err := c.editDiscussionLabels(repo, d.ID, labelIDs, nil); err != nil { + labelNode, err := c.editDiscussionLabels(repo, node.ID, labelIDs, nil) + if err != nil { return nil, err } - d.Labels = resolvedLabels + 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) { - if input.Title == nil && input.Body == nil && input.CategoryID == nil { + hasFieldUpdate := input.Title != nil || input.Body != nil || input.CategoryID != nil + hasLabelUpdate := len(input.AddLabels) > 0 || len(input.RemoveLabels) > 0 + + if !hasFieldUpdate && !hasLabelUpdate { return nil, fmt.Errorf("nothing to update") } - 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 node *discussionListNode - var mutation struct { - UpdateDiscussion struct { - Discussion struct { - discussionListNode - } - } `graphql:"updateDiscussion(input: $input)"` - } + 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 + } - variables := map[string]interface{}{ - "input": gqlInput, + 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 err := c.gql.Mutate(repo.RepoHost(), "UpdateDiscussion", &mutation, variables); err != nil { - return nil, err + if hasLabelUpdate { + allNames := append(input.AddLabels, input.RemoveLabels...) + resolved, err := c.resolveLabels(repo, allNames) + if err != nil { + return nil, err + } + labelByName := make(map[string]string, len(resolved)) + for _, l := range resolved { + labelByName[strings.ToLower(l.Name)] = l.ID + } + + addIDs := make([]string, 0, len(input.AddLabels)) + for _, name := range input.AddLabels { + addIDs = append(addIDs, labelByName[strings.ToLower(name)]) + } + removeIDs := make([]string, 0, len(input.RemoveLabels)) + for _, name := range input.RemoveLabels { + removeIDs = append(removeIDs, labelByName[strings.ToLower(name)]) + } + + labelNode, err := c.editDiscussionLabels(repo, input.DiscussionID, addIDs, removeIDs) + if err != nil { + return nil, err + } + node = labelNode } - d := mapDiscussionFromListNode(mutation.UpdateDiscussion.Discussion.discussionListNode) + d := mapDiscussionFromListNode(*node) - for _, rg := range mutation.UpdateDiscussion.Discussion.ReactionGroups { + for _, rg := range node.ReactionGroups { d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ Content: rg.Content, TotalCount: rg.Users.TotalCount, diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 276c81b95d5..30fe0da8d97 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2720,7 +2720,39 @@ func TestCreate(t *testing.T) { ) reg.Register( httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), - httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + 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": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), ) }, assertDisc: &Discussion{ @@ -2810,7 +2842,39 @@ func TestCreate(t *testing.T) { ) reg.Register( httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), - httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + 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": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + } + } + } + `)), ) }, assertDisc: &Discussion{ @@ -2904,7 +2968,39 @@ func TestCreate(t *testing.T) { assert.Equal(t, []interface{}{"L_bug", "L_enh"}, labelIDs) return true }), - httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + 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{ @@ -3066,12 +3162,42 @@ func TestCreate(t *testing.T) { 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", @@ -3084,7 +3210,8 @@ func TestEditDiscussionLabels(t *testing.T) { assert.Equal(t, []interface{}{"L_old"}, input["labelIds"]) return true }), - httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"__typename":"Labelable"}}}`), + // 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 { @@ -3092,9 +3219,53 @@ func TestEditDiscussionLabels(t *testing.T) { assert.Equal(t, []interface{}{"L_bug", "L_enh"}, input["labelIds"]) return true }), - httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Labelable"}}}`), + 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", @@ -3103,9 +3274,51 @@ func TestEditDiscussionLabels(t *testing.T) { setupMock: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), - httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Labelable"}}}`), + 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", @@ -3114,9 +3327,47 @@ func TestEditDiscussionLabels(t *testing.T) { setupMock: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation RemoveLabelsFromDiscussion\b`), - httpmock.StringResponse(`{"data":{"removeLabelsFromLabelable":{"__typename":"Labelable"}}}`), + 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", @@ -3159,7 +3410,7 @@ func TestEditDiscussionLabels(t *testing.T) { client := newTestDiscussionClient(reg).(*discussionClient) - err := client.editDiscussionLabels(repo, "D_1", tt.addIDs, tt.removeIDs) + node, err := client.editDiscussionLabels(repo, "D_1", tt.addIDs, tt.removeIDs) if tt.wantErr != "" { require.Error(t, err) @@ -3168,6 +3419,12 @@ func TestEditDiscussionLabels(t *testing.T) { } require.NoError(t, err) + if tt.wantNode == nil { + assert.Nil(t, node) + } else { + require.NotNil(t, node) + assert.Equal(t, tt.wantNode(), *node) + } }) } } @@ -3200,6 +3457,8 @@ func TestUpdate(t *testing.T) { Title: &titleStr, Body: &bodyStr, CategoryID: &catID, + AddLabels: []string{"bug", "enhancement"}, + RemoveLabels: []string{"old", "stale"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -3221,7 +3480,7 @@ func TestUpdate(t *testing.T) { "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": []}, + "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", @@ -3233,6 +3492,34 @@ func TestUpdate(t *testing.T) { } `)), ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"}, + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"}, + {"id": "L_old", "name": "old", "color": "000000"}, + {"id": "L_stale", "name": "stale", "color": "111111"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + 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", @@ -3248,7 +3535,7 @@ func TestUpdate(t *testing.T) { Emoji: ":question:", IsAnswerable: true, }, - Labels: []DiscussionLabel{}, + 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), @@ -3337,6 +3624,60 @@ func TestUpdate(t *testing.T) { }, wantErr: "Could not resolve to a Discussion with the global id of 'D_1'.", }, + { + name: "label only update", + input: UpdateDiscussionInput{ + DiscussionID: "D_1", + AddLabels: []string{"bug", "enhancement"}, + RemoveLabels: []string{"old", "stale"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"}, + {"id": "L_enh", "name": "enhancement", "color": "a2eeef"}, + {"id": "L_old", "name": "old", "color": "000000"}, + {"id": "L_stale", "name": "stale", "color": "111111"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + 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 { diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index d725ee92c2f..1d4f7b77f0e 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -336,8 +336,10 @@ type CreateDiscussionInput struct { // 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 + DiscussionID string + Title *string + Body *string + CategoryID *string + AddLabels []string + RemoveLabels []string } From 7c54f0cbcd5cefbcd9936f4cb26509ec48902005 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 26 May 2026 17:42:49 +0100 Subject: [PATCH 78/81] fix(discussion/client): add ListLabels method to client Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client.go | 1 + pkg/cmd/discussion/client/client_impl.go | 65 +++-- pkg/cmd/discussion/client/client_impl_test.go | 270 +++++++++--------- pkg/cmd/discussion/client/client_mock.go | 44 +++ 4 files changed, 221 insertions(+), 159 deletions(-) diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index 7a774deeb90..f56906f7053 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -15,6 +15,7 @@ type DiscussionClient interface { 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) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 02e69dfa3d0..5473ebbd308 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -805,13 +805,8 @@ func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repository }, nil } -// resolveLabels fetches all labels for a repository and matches the requested names -// case-insensitively. Returns an error if any requested label name is not found. -func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []string) ([]DiscussionLabel, error) { - if len(labelNames) == 0 { - return nil, 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 { @@ -824,7 +819,7 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str HasNextPage bool EndCursor string } - } `graphql:"labels(first: 100, after: $endCursor)"` + } `graphql:"labels(first: 100, after: $endCursor, orderBy: {field: NAME, direction: ASC})"` } `graphql:"repository(owner: $owner, name: $name)"` } @@ -834,23 +829,13 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str "endCursor": (*githubv4.String)(nil), } - wanted := make(map[string]bool, len(labelNames)) - for _, n := range labelNames { - wanted[strings.ToLower(n)] = true - } - - found := make(map[string]DiscussionLabel, len(labelNames)) + var labels []DiscussionLabel for { - if err := c.gql.Query(repo.RepoHost(), "RepositoryLabels", &query, variables); err != nil { + if err := c.gql.Query(repo.RepoHost(), "RepositoryLabelsForDiscussions", &query, variables); err != nil { return nil, err } for _, n := range query.Repository.Labels.Nodes { - if wanted[strings.ToLower(n.Name)] { - found[strings.ToLower(n.Name)] = DiscussionLabel{ID: n.ID, Name: n.Name, Color: n.Color} - } - } - if len(found) == len(wanted) { - break + labels = append(labels, DiscussionLabel{ID: n.ID, Name: n.Name, Color: n.Color}) } if !query.Repository.Labels.PageInfo.HasNextPage { break @@ -858,20 +843,40 @@ func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []str variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) } - if len(found) != len(wanted) { - var missing []string - for _, name := range labelNames { - if _, ok := found[strings.ToLower(name)]; !ok { - missing = append(missing, name) - } - } - return nil, fmt.Errorf("labels not found: %s", strings.Join(missing, ", ")) + return labels, nil +} + +// resolveLabels matches the requested label names case-insensitively against +// the repository's labels. Returns an error if any name is not found. +func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []string) ([]DiscussionLabel, error) { + if len(labelNames) == 0 { + return nil, nil + } + + allLabels, err := c.ListLabels(repo) + if err != nil { + return nil, err + } + + byName := make(map[string]DiscussionLabel, len(allLabels)) + for _, l := range allLabels { + byName[strings.ToLower(l.Name)] = l } result := make([]DiscussionLabel, 0, len(labelNames)) + var missing []string for _, name := range labelNames { - result = append(result, found[strings.ToLower(name)]) + if l, ok := byName[strings.ToLower(name)]; ok { + result = append(result, l) + } else { + missing = append(missing, name) + } } + + if len(missing) > 0 { + return nil, fmt.Errorf("labels not found: %s", strings.Join(missing, ", ")) + } + return result, nil } diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 30fe0da8d97..f52e0d246fe 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2685,7 +2685,7 @@ func TestCreate(t *testing.T) { `)), ) reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -2702,7 +2702,7 @@ func TestCreate(t *testing.T) { `)), ) reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -2776,128 +2776,6 @@ func TestCreate(t *testing.T) { UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), }, }, - { - name: "stops paginating labels when all found", - input: CreateDiscussionInput{ - CategoryID: "CAT_1", - Title: "New Discussion", - Body: "Discussion body", - Labels: []string{"bug", "enhancement"}, - }, - 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": [], - "createdAt": "2025-06-01T00:00:00Z", - "updatedAt": "2025-06-01T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", - "locked": false - } - } - } - } - `)), - ) - // Register a single page that returns both labels but claims more pages exist. - // The code should stop paginating once all wanted labels are found. - reg.Register( - httpmock.GraphQL(`query RepositoryLabels\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": true, "endCursor": "LABEL_CUR_999"} - } - } - } - } - `)), - ) - reg.Register( - httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), - 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": [], - "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"}, - }, - 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: "creates discussion with labels", input: CreateDiscussionInput{ @@ -2943,7 +2821,7 @@ func TestCreate(t *testing.T) { `)), ) reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -3041,7 +2919,7 @@ func TestCreate(t *testing.T) { // No CreateDiscussion stub — reg.Verify(t) proves it is never called, // confirming that label validation is atomic with discussion creation. reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -3103,7 +2981,7 @@ func TestCreate(t *testing.T) { `)), ) reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -3159,6 +3037,140 @@ func TestCreate(t *testing.T) { } } +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") @@ -3493,7 +3505,7 @@ func TestUpdate(t *testing.T) { `)), ) reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { @@ -3633,7 +3645,7 @@ func TestUpdate(t *testing.T) { }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index eb71f20e88a..075c2f89ccd 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -42,6 +42,9 @@ var _ DiscussionClient = &DiscussionClientMock{} // 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") // }, @@ -94,6 +97,9 @@ type DiscussionClientMock struct { // 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 @@ -195,6 +201,11 @@ type DiscussionClientMock 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. @@ -259,6 +270,7 @@ type DiscussionClientMock struct { lockGetWithComments sync.RWMutex lockList sync.RWMutex lockListCategories sync.RWMutex + lockListLabels sync.RWMutex lockLock sync.RWMutex lockMarkAnswer sync.RWMutex lockReopen sync.RWMutex @@ -600,6 +612,38 @@ func (mock *DiscussionClientMock) ListCategoriesCalls() []struct { 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 { From a5afebdf0f7913568d11922d9ce187949cefe107 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 27 May 2026 12:39:14 +0100 Subject: [PATCH 79/81] fix(discussion/client): use label ids as input Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/client/client_impl.go | 75 +---- pkg/cmd/discussion/client/client_impl_test.go | 268 +----------------- pkg/cmd/discussion/client/types.go | 6 +- pkg/cmd/discussion/create/create.go | 17 +- pkg/cmd/discussion/create/create_test.go | 8 +- pkg/cmd/discussion/shared/labels.go | 36 +++ pkg/cmd/discussion/shared/labels_test.go | 73 +++++ 7 files changed, 151 insertions(+), 332 deletions(-) create mode 100644 pkg/cmd/discussion/shared/labels.go create mode 100644 pkg/cmd/discussion/shared/labels_test.go diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 5473ebbd308..318caefb34b 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -846,40 +846,6 @@ func (c *discussionClient) ListLabels(repo ghrepo.Interface) ([]DiscussionLabel, return labels, nil } -// resolveLabels matches the requested label names case-insensitively against -// the repository's labels. Returns an error if any name is not found. -func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []string) ([]DiscussionLabel, error) { - if len(labelNames) == 0 { - return nil, nil - } - - allLabels, err := c.ListLabels(repo) - if err != nil { - return nil, err - } - - byName := make(map[string]DiscussionLabel, len(allLabels)) - for _, l := range allLabels { - byName[strings.ToLower(l.Name)] = l - } - - result := make([]DiscussionLabel, 0, len(labelNames)) - var missing []string - for _, name := range labelNames { - if l, ok := byName[strings.ToLower(name)]; ok { - result = append(result, l) - } else { - missing = append(missing, name) - } - } - - if len(missing) > 0 { - return nil, fmt.Errorf("labels not found: %s", strings.Join(missing, ", ")) - } - - return result, 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. @@ -956,16 +922,6 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) } - // Resolve labels before creating the discussion so that an unknown label - // name aborts without leaving a half-created discussion behind. - var resolvedLabels []DiscussionLabel - if len(input.Labels) > 0 { - resolvedLabels, err = c.resolveLabels(repo, input.Labels) - if err != nil { - return nil, err - } - } - var mutation struct { CreateDiscussion struct { Discussion struct { @@ -989,12 +945,8 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI node := &mutation.CreateDiscussion.Discussion.discussionListNode - if len(resolvedLabels) > 0 { - labelIDs := make([]string, len(resolvedLabels)) - for i, l := range resolvedLabels { - labelIDs[i] = l.ID - } - labelNode, err := c.editDiscussionLabels(repo, node.ID, labelIDs, nil) + if len(input.LabelIDs) > 0 { + labelNode, err := c.editDiscussionLabels(repo, node.ID, input.LabelIDs, nil) if err != nil { return nil, err } @@ -1015,7 +967,7 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI func (c *discussionClient) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) { hasFieldUpdate := input.Title != nil || input.Body != nil || input.CategoryID != nil - hasLabelUpdate := len(input.AddLabels) > 0 || len(input.RemoveLabels) > 0 + hasLabelUpdate := len(input.AddLabelIDs) > 0 || len(input.RemoveLabelIDs) > 0 if !hasFieldUpdate && !hasLabelUpdate { return nil, fmt.Errorf("nothing to update") @@ -1058,26 +1010,7 @@ func (c *discussionClient) Update(repo ghrepo.Interface, input UpdateDiscussionI } if hasLabelUpdate { - allNames := append(input.AddLabels, input.RemoveLabels...) - resolved, err := c.resolveLabels(repo, allNames) - if err != nil { - return nil, err - } - labelByName := make(map[string]string, len(resolved)) - for _, l := range resolved { - labelByName[strings.ToLower(l.Name)] = l.ID - } - - addIDs := make([]string, 0, len(input.AddLabels)) - for _, name := range input.AddLabels { - addIDs = append(addIDs, labelByName[strings.ToLower(name)]) - } - removeIDs := make([]string, 0, len(input.RemoveLabels)) - for _, name := range input.RemoveLabels { - removeIDs = append(removeIDs, labelByName[strings.ToLower(name)]) - } - - labelNode, err := c.editDiscussionLabels(repo, input.DiscussionID, addIDs, removeIDs) + labelNode, err := c.editDiscussionLabels(repo, input.DiscussionID, input.AddLabelIDs, input.RemoveLabelIDs) if err != nil { return nil, err } diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index f52e0d246fe..089aa033f27 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2641,148 +2641,12 @@ func TestCreate(t *testing.T) { wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.", }, { - name: "paginates labels across multiple pages", + name: "creates discussion with labels via addLabels mutation", input: CreateDiscussionInput{ CategoryID: "CAT_1", Title: "New Discussion", Body: "Discussion body", - Labels: []string{"bug", "enhancement"}, - }, - 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": [], - "createdAt": "2025-06-01T00:00:00Z", - "updatedAt": "2025-06-01T00:00:00Z", - "closedAt": "0001-01-01T00:00:00Z", - "locked": false - } - } - } - } - `)), - ) - 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": "LABEL_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": ""} - } - } - } - } - `)), - ) - reg.Register( - httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), - 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": [], - "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"}, - }, - 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: "creates discussion with labels", - input: CreateDiscussionInput{ - CategoryID: "CAT_1", - Title: "New Discussion", - Body: "Discussion body", - Labels: []string{"bug", "enhancement"}, + LabelIDs: []string{"L_bug", "L_enh"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -2820,24 +2684,6 @@ func TestCreate(t *testing.T) { } `)), ) - 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": ""} - } - } - } - } - `)), - ) reg.Register( httpmock.GraphQLMutationMatcher(`mutation AddLabelsToDiscussion\b`, func(input map[string]interface{}) bool { assert.Equal(t, "D_new", input["labelableId"]) @@ -2903,46 +2749,13 @@ func TestCreate(t *testing.T) { UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), }, }, - { - name: "label not found returns error without creating discussion", - input: CreateDiscussionInput{ - CategoryID: "CAT_1", - Title: "Test", - Body: "Body", - Labels: []string{"nonexistent", "also-missing"}, - }, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query RepositoryMeta\b`), - httpmock.StringResponse(repoMetaResp("R_1", true)), - ) - // No CreateDiscussion stub — reg.Verify(t) proves it is never called, - // confirming that label validation is atomic with discussion creation. - reg.Register( - httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), - httpmock.StringResponse(heredoc.Doc(` - { - "data": { - "repository": { - "labels": { - "nodes": [], - "pageInfo": {"hasNextPage": false, "endCursor": ""} - } - } - } - } - `)), - ) - }, - wantErr: `labels not found: nonexistent, also-missing`, - }, { name: "add labels mutation failure returns error", input: CreateDiscussionInput{ CategoryID: "CAT_1", Title: "Test", Body: "Body", - Labels: []string{"bug"}, + LabelIDs: []string{"L_bug"}, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -2980,23 +2793,6 @@ func TestCreate(t *testing.T) { } `)), ) - reg.Register( - httpmock.GraphQL(`query RepositoryLabelsForDiscussions\b`), - httpmock.StringResponse(heredoc.Doc(` - { - "data": { - "repository": { - "labels": { - "nodes": [ - {"id": "L_bug", "name": "bug", "color": "d73a4a"} - ], - "pageInfo": {"hasNextPage": false, "endCursor": ""} - } - } - } - } - `)), - ) reg.Register( httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), httpmock.StringResponse(heredoc.Doc(` @@ -3465,12 +3261,12 @@ func TestUpdate(t *testing.T) { { name: "maps all fields", input: UpdateDiscussionInput{ - DiscussionID: "D_1", - Title: &titleStr, - Body: &bodyStr, - CategoryID: &catID, - AddLabels: []string{"bug", "enhancement"}, - RemoveLabels: []string{"old", "stale"}, + 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( @@ -3504,26 +3300,6 @@ func TestUpdate(t *testing.T) { } `)), ) - 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"}, - {"id": "L_old", "name": "old", "color": "000000"}, - {"id": "L_stale", "name": "stale", "color": "111111"} - ], - "pageInfo": {"hasNextPage": false, "endCursor": ""} - } - } - } - } - `)), - ) 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}}}}`), @@ -3639,31 +3415,11 @@ func TestUpdate(t *testing.T) { { name: "label only update", input: UpdateDiscussionInput{ - DiscussionID: "D_1", - AddLabels: []string{"bug", "enhancement"}, - RemoveLabels: []string{"old", "stale"}, + 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(`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"}, - {"id": "L_old", "name": "old", "color": "000000"}, - {"id": "L_stale", "name": "stale", "color": "111111"} - ], - "pageInfo": {"hasNextPage": false, "endCursor": ""} - } - } - } - } - `)), - ) 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}}}}`), diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 1d4f7b77f0e..d1cb14cdd62 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -330,7 +330,7 @@ type CreateDiscussionInput struct { CategoryID string Title string Body string - Labels []string + LabelIDs []string } // UpdateDiscussionInput holds optional parameters for updating a discussion. @@ -340,6 +340,6 @@ type UpdateDiscussionInput struct { Title *string Body *string CategoryID *string - AddLabels []string - RemoveLabels []string + AddLabelIDs []string + RemoveLabelIDs []string } diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go index 5ff4d0a03bc..9c377c7206c 100644 --- a/pkg/cmd/discussion/create/create.go +++ b/pkg/cmd/discussion/create/create.go @@ -146,11 +146,26 @@ func createRun(opts *CreateOptions) error { } } + 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, - Labels: opts.Labels, + LabelIDs: labelIDs, } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go index 8aa273aaef4..7d72b712ba2 100644 --- a/pkg/cmd/discussion/create/create_test.go +++ b/pkg/cmd/discussion/create/create_test.go @@ -166,8 +166,14 @@ func TestCreateRun(t *testing.T) { 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{"enhancement", "bug"}, input.Labels) + assert.Equal(t, []string{"L_enh", "L_bug"}, input.LabelIDs) return sampleDiscussion(), 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) + } + }) + } +} From 6c6390ad9495739ffab43a6b0491de729eeb2439 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 27 May 2026 13:27:11 +0100 Subject: [PATCH 80/81] feat(discussion edit): support editing discussion labels --- pkg/cmd/discussion/edit/edit.go | 139 ++++++++++------- pkg/cmd/discussion/edit/edit_test.go | 218 ++++++++++++++++++++++++--- 2 files changed, 285 insertions(+), 72 deletions(-) diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go index 20400a7326a..1a0d8e6d202 100644 --- a/pkg/cmd/discussion/edit/edit.go +++ b/pkg/cmd/discussion/edit/edit.go @@ -23,11 +23,19 @@ type EditOptions struct { 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. @@ -42,24 +50,24 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "edit { | }", Short: "Edit a discussion (preview)", - Long: heredoc.Docf(` + Long: heredoc.Doc(` Edit a GitHub Discussion. - With %[1]s--title%[1]s, %[1]s--body%[1]s, and %[1]s--category%[1]s flags, the discussion is updated - non-interactively. Omitting all flags triggers interactive prompts when connected to a terminal. - `, "`"), + 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 - # Set a new title - $ gh discussion edit 123 --title "Updated title" - - # Change the category - $ gh discussion edit 123 --category "Ideas" + # 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 { @@ -83,11 +91,19 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts.DiscussionNumber = number - noFlagsSet := opts.Title == "" && opts.Body == "" && opts.BodyFile == "" && opts.Category == "" + flags := cmd.Flags() + opts.TitleProvided = flags.Changed("title") + opts.BodyProvided = flags.Changed("body") || flags.Changed("body-file") + opts.CategoryProvided = 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 of --title, --body, --body-file, or --category when not running interactively") + return cmdutil.FlagErrorf("specify at least one flag to update the discussion non-interactively") } + opts.Interactive = noFlagsSet + if runF != nil { return runF(opts) } @@ -101,6 +117,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman 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 } @@ -120,47 +138,40 @@ func editRun(opts *EditOptions) error { discussion, err := c.GetByNumber(repo, opts.DiscussionNumber) opts.IO.StopProgressIndicator() if err != nil { - return fmt.Errorf("fetching discussion: %w", err) - } - - // Resolve body from file if provided. - if opts.BodyFile != "" { - bodyBytes, err := cmdutil.ReadFile(opts.BodyFile, opts.IO.In) - if err != nil { - return err - } - opts.Body = string(bodyBytes) + return err } input := client.UpdateDiscussionInput{ DiscussionID: discussion.ID, } - // noFlagsSet omits BodyFile intentionally: ReadFile above already copied its - // contents into opts.Body, so Body == "" implies no body update was requested. - noFlagsSet := opts.Title == "" && opts.Body == "" && opts.Category == "" - if noFlagsSet { - // Interactive mode: prompt user to select which fields to edit. - if err := promptEdit(opts, discussion, c, repo, &input); err != nil { + if opts.Interactive { + changed, err := promptEdit(opts, discussion, c, repo, &input) + if err != nil { return err } - // If the user dismissed the prompt without selecting anything, skip the - // API call — there is nothing to update. - if input.Title == nil && input.Body == nil && input.CategoryID == nil { - return nil + + if !changed { + return fmt.Errorf("no changes made") } } else { - // Non-interactive: apply only the flags that were set. - if opts.Title != "" { + if opts.TitleProvided { if strings.TrimSpace(opts.Title) == "" { return cmdutil.FlagErrorf("title cannot be blank") } input.Title = &opts.Title } - if opts.Body != "" { + 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.Category != "" { + if opts.CategoryProvided { opts.IO.StartProgressIndicator() categories, err := c.ListCategories(repo) opts.IO.StopProgressIndicator() @@ -173,68 +184,90 @@ func editRun(opts *EditOptions) error { } 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 fmt.Errorf("failed to update discussion: %w", err) + return err } fmt.Fprintln(opts.IO.Out, updated.URL) return nil } -// promptEdit runs the interactive flow, populating input with user choices. -func promptEdit(opts *EditOptions, discussion *client.Discussion, c client.DiscussionClient, repo ghrepo.Interface, input *client.UpdateDiscussionInput) error { - choices := []string{"title", "body", "category"} +// 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 err + return false, err } if len(selected) == 0 { - return nil + return false, nil } for _, idx := range selected { switch choices[idx] { - case "title": - title, err := opts.Prompter.Input("Discussion title", discussion.Title) + case "Title": + title, err := opts.Prompter.Input("Title", discussion.Title) if err != nil { - return err + return false, err } if strings.TrimSpace(title) == "" { - return fmt.Errorf("title cannot be blank") + return false, fmt.Errorf("title cannot be blank") } input.Title = &title - case "body": - body, err := opts.Prompter.MarkdownEditor("Discussion body", discussion.Body, false) + case "Body": + body, err := opts.Prompter.MarkdownEditor("Body", discussion.Body, false) if err != nil { - return err + return false, err } input.Body = &body - case "category": + case "Category": opts.IO.StartProgressIndicator() categories, err := c.ListCategories(repo) opts.IO.StopProgressIndicator() if err != nil { - return fmt.Errorf("fetching categories: %w", err) + 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("Discussion category", currentName, names) + idx, err := opts.Prompter.Select("Category", currentName, names) if err != nil { - return err + return false, err } input.CategoryID = &categories[idx].ID } } - return nil + return true, nil } diff --git a/pkg/cmd/discussion/edit/edit_test.go b/pkg/cmd/discussion/edit/edit_test.go index d4289f8554d..5baefc09d02 100644 --- a/pkg/cmd/discussion/edit/edit_test.go +++ b/pkg/cmd/discussion/edit/edit_test.go @@ -32,8 +32,11 @@ func TestNewCmdEdit(t *testing.T) { isTTY: true, wantOpts: EditOptions{ DiscussionNumber: 123, + TitleProvided: true, Title: "New title", + BodyProvided: true, Body: "New body", + CategoryProvided: true, Category: "Ideas", }, }, @@ -43,9 +46,30 @@ func TestNewCmdEdit(t *testing.T) { 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", @@ -56,7 +80,7 @@ func TestNewCmdEdit(t *testing.T) { name: "no flags no TTY", args: "123", isTTY: false, - wantErr: "specify at least one of --title, --body, --body-file, or --category when not running interactively", + wantErr: "specify at least one flag to update the discussion non-interactively", }, { name: "no args", @@ -99,9 +123,16 @@ func TestNewCmdEdit(t *testing.T) { } 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() @@ -117,6 +148,7 @@ func TestEditRun(t *testing.T) { 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 @@ -126,7 +158,8 @@ func TestEditRun(t *testing.T) { { name: "success non-tty title only", opts: EditOptions{ - Title: "Updated title", + Title: "Updated title", + TitleProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -146,7 +179,8 @@ func TestEditRun(t *testing.T) { { name: "success non-tty body only", opts: EditOptions{ - Body: "Updated body", + Body: "Updated body", + BodyProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -165,7 +199,8 @@ func TestEditRun(t *testing.T) { { name: "success non-tty category change", opts: EditOptions{ - Category: "Q&A", + Category: "Q&A", + CategoryProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -184,12 +219,94 @@ func TestEditRun(t *testing.T) { }, 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", + 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) { @@ -198,6 +315,12 @@ func TestEditRun(t *testing.T) { 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) @@ -205,6 +328,8 @@ func TestEditRun(t *testing.T) { 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 } }, @@ -213,7 +338,8 @@ func TestEditRun(t *testing.T) { { name: "non-tty blank title returns error", opts: EditOptions{ - Title: " ", + Title: " ", + TitleProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -225,7 +351,8 @@ func TestEditRun(t *testing.T) { { name: "non-tty unknown category", opts: EditOptions{ - Category: "nonexistent", + Category: "nonexistent", + CategoryProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -240,7 +367,8 @@ func TestEditRun(t *testing.T) { { name: "non-tty list categories error", opts: EditOptions{ - Category: "General", + Category: "General", + CategoryProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -250,24 +378,44 @@ func TestEditRun(t *testing.T) { return nil, fmt.Errorf("network error") } }, - wantErr: "fetching categories: 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", + 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: "fetching discussion: not found", + wantErr: "not found", }, { name: "Update error", opts: EditOptions{ - Title: "Updated title", + Title: "Updated title", + TitleProvided: true, }, setupMock: func(m *client.DiscussionClientMock) { m.GetByNumberFunc = func(repo ghrepo.Interface, number int) (*client.Discussion, error) { @@ -277,11 +425,12 @@ func TestEditRun(t *testing.T) { return nil, fmt.Errorf("mutation failed") } }, - wantErr: "failed to update discussion: 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 @@ -296,7 +445,7 @@ func TestEditRun(t *testing.T) { }, prompter: &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { - assert.Equal(t, []string{"title", "body", "category"}, options) + assert.Equal(t, []string{"Title", "Body", "Category"}, options) return []int{0}, nil }, InputFunc: func(prompt, defaultValue string) (string, error) { @@ -309,6 +458,7 @@ func TestEditRun(t *testing.T) { { 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 @@ -334,6 +484,7 @@ func TestEditRun(t *testing.T) { { 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 @@ -364,22 +515,25 @@ func TestEditRun(t *testing.T) { { 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 } - // UpdateFunc intentionally not set: Update must not be called when nothing is selected. }, prompter: &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { return []int{}, nil }, }, - wantOut: "", + 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 @@ -397,6 +551,7 @@ func TestEditRun(t *testing.T) { { 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 @@ -412,14 +567,39 @@ func TestEditRun(t *testing.T) { }, 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, _, stdout, _ := iostreams.Test() + 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) From 4e3a173ac33f393f5f4bb025fa3018580acf3647 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 28 May 2026 10:43:12 +0100 Subject: [PATCH 81/81] fix(discussion edit): check body related flags changed state instead of value Signed-off-by: Babak K. Shandiz --- pkg/cmd/discussion/edit/edit.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/discussion/edit/edit.go b/pkg/cmd/discussion/edit/edit.go index 1a0d8e6d202..b63774c5226 100644 --- a/pkg/cmd/discussion/edit/edit.go +++ b/pkg/cmd/discussion/edit/edit.go @@ -71,11 +71,6 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := cmdutil.MutuallyExclusive("specify only one of --body or --body-file", - opts.Body != "", opts.BodyFile != ""); err != nil { - return err - } - number, repo, err := shared.ParseDiscussionArg(args[0]) if err != nil { return cmdutil.FlagErrorWrap(err) @@ -91,10 +86,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts.DiscussionNumber = number - flags := cmd.Flags() - opts.TitleProvided = flags.Changed("title") - opts.BodyProvided = flags.Changed("body") || flags.Changed("body-file") - opts.CategoryProvided = flags.Changed("category") + 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