diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go new file mode 100644 index 000000000..b698dc896 --- /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 000000000..e5db32dd5 --- /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 000000000..4f8227d5f --- /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 000000000..9870416ad --- /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 000000000..6db07c5d8 --- /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 000000000..d0f34e04b --- /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 000000000..47d750314 --- /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 ed33f568e..a4bcf89fa 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))