From 45c68b48da0df84f88b30d2e57c656106123497f Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Thu, 2 Apr 2026 10:34:14 -0500 Subject: [PATCH] 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 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))